fix(supply-chain): verify CI and production inputs

Pin external actions, images, toolchains, native archives, and tvOS engine artifacts; enforce fail-closed CI checks and keep website privacy disclosures aligned with shipped behavior.
This commit is contained in:
edde746
2026-07-24 03:56:40 +02:00
parent b41fb4fe75
commit 09656fa4d3
63 changed files with 5146 additions and 560 deletions
+318
View File
@@ -0,0 +1,318 @@
{
"schemaVersion": 1,
"reviewedOn": "2026-07-21",
"accepted": [
{
"id": 1113317,
"package": "@sveltejs/kit",
"severity": "moderate",
"vulnerableRange": ">=2.49.0 <=2.52.1",
"expiresOn": "2026-10-19",
"rationale": "GHSA-88qp-p4qg-rqm6: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling."
},
{
"id": 1113318,
"package": "@sveltejs/kit",
"severity": "moderate",
"vulnerableRange": ">=2.49.0 <=2.52.1",
"expiresOn": "2026-10-19",
"rationale": "GHSA-vrhm-gvg7-fpcf: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling."
},
{
"id": 1113631,
"package": "@sveltejs/kit",
"severity": "low",
"vulnerableRange": ">=2.49.0 <=2.53.2",
"expiresOn": "2026-10-19",
"rationale": "GHSA-fpg4-jhqr-589c: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling."
},
{
"id": 1116432,
"package": "@sveltejs/kit",
"severity": "moderate",
"vulnerableRange": "<=2.57.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-3f6h-2hrp-w5wx: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling."
},
{
"id": 1116433,
"package": "@sveltejs/kit",
"severity": "high",
"vulnerableRange": "<=2.57.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-2crg-3p73-43xp: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling."
},
{
"id": 1122155,
"package": "@sveltejs/kit",
"severity": "moderate",
"vulnerableRange": ">=2.38.0 <=2.60.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-hgv7-v322-mmgr: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling."
},
{
"id": 1103907,
"package": "cookie",
"severity": "low",
"vulnerableRange": "<0.7.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-pxg6-pf52-xh8x: Static site has no cookies, hooks, actions, or forms; cookie serialization is not exercised."
},
{
"id": 1113319,
"package": "devalue",
"severity": "low",
"vulnerableRange": "<=5.6.2",
"expiresOn": "2026-10-19",
"rationale": "GHSA-33hq-fvwr-56pm: Prerender serialization receives fixed price/rating/count data; the site does not call parse, unflatten, or uneval."
},
{
"id": 1113320,
"package": "devalue",
"severity": "low",
"vulnerableRange": "<=5.6.2",
"expiresOn": "2026-10-19",
"rationale": "GHSA-8qm3-746x-r74r: Prerender serialization receives fixed price/rating/count data; the site does not call parse, unflatten, or uneval."
},
{
"id": 1114438,
"package": "devalue",
"severity": "moderate",
"vulnerableRange": "<5.6.4",
"expiresOn": "2026-10-19",
"rationale": "GHSA-cfw5-2vxh-hr84: Prerender serialization receives fixed price/rating/count data; the site does not call parse, unflatten, or uneval."
},
{
"id": 1121800,
"package": "devalue",
"severity": "low",
"vulnerableRange": ">=4.0.0 <5.6.4",
"expiresOn": "2026-10-19",
"rationale": "GHSA-mwv9-gp5h-frr4: Prerender serialization receives fixed price/rating/count data; the site does not call parse, unflatten, or uneval."
},
{
"id": 1115551,
"package": "picomatch",
"severity": "moderate",
"vulnerableRange": ">=4.0.0 <4.0.4",
"expiresOn": "2026-10-19",
"rationale": "GHSA-3v7f-55p6-f55p: Build-only glob tooling consumes repository-controlled patterns; no deployed runtime or untrusted glob input exists."
},
{
"id": 1115554,
"package": "picomatch",
"severity": "high",
"vulnerableRange": ">=4.0.0 <4.0.4",
"expiresOn": "2026-10-19",
"rationale": "GHSA-c2c7-rcm5-vvqj: Build-only glob tooling consumes repository-controlled patterns; no deployed runtime or untrusted glob input exists."
},
{
"id": 1117015,
"package": "postcss",
"severity": "moderate",
"vulnerableRange": "<8.5.10",
"expiresOn": "2026-10-19",
"rationale": "GHSA-qx2v-qp2m-jg93: Build-only CSS tooling consumes checked-in CSS; no untrusted CSS reaches stringify and the tooling is not deployed."
},
{
"id": 1113515,
"package": "rollup",
"severity": "high",
"vulnerableRange": ">=4.0.0 <4.59.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-mw96-cpmx-2vgc: Build-only bundling processes repository-controlled paths and is absent from the deployed static output."
},
{
"id": 1113416,
"package": "svelte",
"severity": "moderate",
"vulnerableRange": "<=5.51.4",
"expiresOn": "2026-10-19",
"rationale": "GHSA-crpf-4hrx-3jrp: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
},
{
"id": 1113418,
"package": "svelte",
"severity": "moderate",
"vulnerableRange": "<=5.51.4",
"expiresOn": "2026-10-19",
"rationale": "GHSA-m56q-vw4c-c2cp: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
},
{
"id": 1113419,
"package": "svelte",
"severity": "moderate",
"vulnerableRange": "<=5.51.4",
"expiresOn": "2026-10-19",
"rationale": "GHSA-f7gr-6p89-r883: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
},
{
"id": 1113420,
"package": "svelte",
"severity": "moderate",
"vulnerableRange": ">=5.39.3 <5.51.5",
"expiresOn": "2026-10-19",
"rationale": "GHSA-h7h7-mm68-gmrc: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
},
{
"id": 1114402,
"package": "svelte",
"severity": "moderate",
"vulnerableRange": "<=5.53.4",
"expiresOn": "2026-10-19",
"rationale": "GHSA-phwv-c562-gvmh: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
},
{
"id": 1118900,
"package": "svelte",
"severity": "moderate",
"vulnerableRange": ">=5.46.0 <=5.55.6",
"expiresOn": "2026-10-19",
"rationale": "GHSA-f3cj-j4f6-wq85: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
},
{
"id": 1120446,
"package": "svelte",
"severity": "moderate",
"vulnerableRange": "<=5.55.6",
"expiresOn": "2026-10-19",
"rationale": "GHSA-rcqx-6q8c-2c42: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
},
{
"id": 1120449,
"package": "svelte",
"severity": "moderate",
"vulnerableRange": "<=5.55.6",
"expiresOn": "2026-10-19",
"rationale": "GHSA-pr6f-5x2q-rwfp: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
},
{
"id": 1114591,
"package": "undici",
"severity": "high",
"vulnerableRange": ">=7.0.0 <7.24.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-f269-vfmq-vjvj: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
},
{
"id": 1114593,
"package": "undici",
"severity": "moderate",
"vulnerableRange": ">=7.0.0 <7.24.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-2mjp-6q6p-2qxm: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
},
{
"id": 1114637,
"package": "undici",
"severity": "high",
"vulnerableRange": ">=7.0.0 <7.24.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-vrm6-8vpv-qv8q: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
},
{
"id": 1114639,
"package": "undici",
"severity": "high",
"vulnerableRange": ">=7.0.0 <7.24.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-v9p9-hfj2-hcw8: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
},
{
"id": 1114641,
"package": "undici",
"severity": "moderate",
"vulnerableRange": ">=7.0.0 <7.24.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-4992-7rv2-5pvq: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
},
{
"id": 1114643,
"package": "undici",
"severity": "moderate",
"vulnerableRange": ">=7.17.0 <7.24.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-phc3-fgpg-7m6h: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
},
{
"id": 1121241,
"package": "undici",
"severity": "moderate",
"vulnerableRange": ">=7.0.0 <7.28.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-p88m-4jfj-68fv: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
},
{
"id": 1121244,
"package": "undici",
"severity": "high",
"vulnerableRange": ">=7.0.0 <7.28.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-vxpw-j846-p89q: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
},
{
"id": 1121249,
"package": "undici",
"severity": "low",
"vulnerableRange": ">=7.0.0 <7.28.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-35p6-xmwp-9g52: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
},
{
"id": 1121254,
"package": "undici",
"severity": "low",
"vulnerableRange": ">=7.0.0 <7.28.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-g8m3-5g58-fq7m: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
},
{
"id": 1121428,
"package": "undici",
"severity": "moderate",
"vulnerableRange": ">=7.0.0 <7.28.0",
"expiresOn": "2026-10-19",
"rationale": "GHSA-pr7r-676h-xcf6: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
},
{
"id": 1116230,
"package": "vite",
"severity": "moderate",
"vulnerableRange": ">=7.0.0 <=7.3.1",
"expiresOn": "2026-10-19",
"rationale": "GHSA-4w7w-66w2-5vf9: Development/build tooling is not deployed; its servers are not exposed and inputs are repository-controlled."
},
{
"id": 1116232,
"package": "vite",
"severity": "high",
"vulnerableRange": ">=7.1.0 <=7.3.1",
"expiresOn": "2026-10-19",
"rationale": "GHSA-v2wj-q39q-566r: Development/build tooling is not deployed; its servers are not exposed and inputs are repository-controlled."
},
{
"id": 1116235,
"package": "vite",
"severity": "high",
"vulnerableRange": ">=7.0.0 <=7.3.1",
"expiresOn": "2026-10-19",
"rationale": "GHSA-p9ff-h696-f583: Development/build tooling is not deployed; its servers are not exposed and inputs are repository-controlled."
},
{
"id": 1120785,
"package": "vite",
"severity": "moderate",
"vulnerableRange": ">=7.0.0 <=7.3.4",
"expiresOn": "2026-10-19",
"rationale": "GHSA-v6wh-96g9-6wx3: Development/build tooling is not deployed; its servers are not exposed and inputs are repository-controlled."
},
{
"id": 1123526,
"package": "vite",
"severity": "high",
"vulnerableRange": ">=7.0.0 <=7.3.4",
"expiresOn": "2026-10-19",
"rationale": "GHSA-fx2h-pf6j-xcff: Development/build tooling is not deployed; its servers are not exposed and inputs are repository-controlled."
}
]
}
+2
View File
@@ -6,6 +6,8 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"audit": "python3 ../scripts/check_bun_audit.py --project . --baseline bun_audit_baseline.json",
"test": "bun test",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
-9
View File
@@ -1,9 +0,0 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 32 358.32 399.86'>
<defs>
<linearGradient id='logo-gradient' x1='22.67' y1='425.72' x2='201.83' y2='115.4' gradientUnits='userSpaceOnUse'>
<stop offset='0' stop-color='#ab543a'/>
<stop offset='1' stop-color='#ff7e57'/>
</linearGradient>
</defs>
<path fill='url(#logo-gradient)' d='M335.65,192.66L68.01,38.14C37.78,20.69,0,42.5,0,77.41v309.04c0,34.91,37.78,56.72,68.01,39.27l267.64-154.52c30.23-17.45,30.23-61.08,0-78.54ZM255.53,276.8c-19.1,17.66-38.75,26.49-58.4,26.49s-39.29-8.83-58.39-26.49c-14.39-13.3-28.55-20.05-42.11-20.05s-27.72,6.75-42.1,20.05c-4.87,4.5-12.46,4.2-16.96-.67-4.5-4.86-4.2-12.45.67-16.95,38.2-35.33,78.59-35.33,116.79,0,14.38,13.3,28.55,20.04,42.1,20.04s27.72-6.74,42.11-20.04c4.86-4.5,12.46-4.21,16.95.66,4.5,4.87,4.21,12.46-.66,16.96ZM255.53,204.04c-19.1,17.67-38.75,26.5-58.4,26.5s-39.29-8.83-58.39-26.5c-14.39-13.3-28.55-20.04-42.11-20.04s-27.72,6.74-42.1,20.04c-4.87,4.5-12.46,4.21-16.96-.66s-4.2-12.46.67-16.96c38.2-35.32,78.59-35.32,116.79,0,14.38,13.3,28.55,20.05,42.1,20.05s27.72-6.75,42.11-20.05c4.86-4.5,12.46-4.2,16.95.67,4.5,4.86,4.21,12.45-.66,16.95Z'/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -5,31 +5,27 @@
import AmazonIcon from "~icons/cib/amazon";
import ChevronDownIcon from "~icons/heroicons/chevron-down-solid";
import WindowsIcon from "./WindowsIcon.svelte";
import { linuxArchitectures } from "$lib/content/downloads";
const linuxArchitectures = [
{
label: "x64 (Intel/AMD)",
formats: [
{ label: ".deb (Debian/Ubuntu)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb" },
{ label: ".rpm (Fedora/RHEL)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm" },
{ label: ".pkg.tar.zst (Arch)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst" },
{ label: ".tar.gz (Portable)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz" },
],
},
{
label: "ARM64",
formats: [
{ label: ".deb (Debian/Ubuntu)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb" },
{ label: ".rpm (Fedora/RHEL)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm" },
{ label: ".pkg.tar.zst (Arch)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst" },
{ label: ".tar.gz (Portable)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz" },
],
},
];
const componentId = $props.id();
const linuxPanelId = `${componentId}-linux-downloads`;
let linuxOpen = $state(false);
let hovered = $state(false);
let showDropdown = $derived(linuxOpen || hovered);
function hoverDisclosure(node: HTMLElement) {
const update = (event: PointerEvent) => {
if (event.pointerType === 'mouse') hovered = event.type === 'pointerenter';
};
node.addEventListener('pointerenter', update);
node.addEventListener('pointerleave', update);
return {
destroy() {
node.removeEventListener('pointerenter', update);
node.removeEventListener('pointerleave', update);
},
};
}
</script>
<svelte:window onclick={() => { linuxOpen = false; }} />
@@ -87,17 +83,12 @@
</a>
<!-- Linux dropdown -->
<div
class="linux-control"
role="group"
onpointerenter={(e) => { if (e.pointerType === 'mouse') hovered = true; }}
onpointerleave={(e) => { if (e.pointerType === 'mouse') hovered = false; }}
>
<div class="linux-control" use:hoverDisclosure>
<button
type="button"
onclick={(e) => { e.stopPropagation(); linuxOpen = !linuxOpen; }}
aria-expanded={showDropdown}
aria-haspopup="true"
aria-controls={linuxPanelId}
class="desktop-button linux-button"
class:active={showDropdown}
>
@@ -109,17 +100,19 @@
</button>
<div
role="menu"
id={linuxPanelId}
class="linux-menu"
class:open={showDropdown}
aria-hidden={!showDropdown}
inert={!showDropdown}
>
{#each linuxArchitectures as arch, i}
{#if i > 0}
<div class="linux-separator"></div>
<div class="linux-separator" aria-hidden="true"></div>
{/if}
<div class="linux-arch-label">{arch.label}</div>
{#each arch.formats as format}
<a href={format.url} role="menuitem" onclick={() => { linuxOpen = false; }} class="linux-menu-item">
<a href={format.url} onclick={() => { linuxOpen = false; }} class="linux-menu-item">
{format.label}
</a>
{/each}
+1 -1
View File
@@ -40,7 +40,7 @@
font-weight: 700;
}
.footer-logo :global(svg) {
.footer-logo :global(img) {
width: 1.5rem;
height: 1.5rem;
}
+1 -1
View File
@@ -113,7 +113,7 @@
font-weight: 700;
}
.brand-logo :global(svg) {
.brand-logo :global(img) {
width: 2.25rem;
height: 2.25rem;
}
+16 -16
View File
@@ -1,20 +1,20 @@
<script lang="ts">
let { class: className = '', gradient = true }: { class?: string; gradient?: boolean } = $props();
import logoUrl from '$lib/assets/favicon.svg';
const gradientId = $props.id();
let { class: className = '' }: { class?: string } = $props();
</script>
<svg class={className} viewBox="0 32 358.32 399.86" xmlns="http://www.w3.org/2000/svg">
{#if gradient}
<defs>
<linearGradient id={gradientId} x1="22.67" y1="425.72" x2="201.83" y2="115.4" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ab543a" />
<stop offset="1" stop-color="#ff7e57" />
</linearGradient>
</defs>
{/if}
<path
fill={gradient ? `url(#${gradientId})` : 'currentColor'}
d="M335.65,192.66L68.01,38.14C37.78,20.69,0,42.5,0,77.41v309.04c0,34.91,37.78,56.72,68.01,39.27l267.64-154.52c30.23-17.45,30.23-61.08,0-78.54ZM255.53,276.8c-19.1,17.66-38.75,26.49-58.4,26.49s-39.29-8.83-58.39-26.49c-14.39-13.3-28.55-20.05-42.11-20.05s-27.72,6.75-42.1,20.05c-4.87,4.5-12.46,4.2-16.96-.67-4.5-4.86-4.2-12.45.67-16.95,38.2-35.33,78.59-35.33,116.79,0,14.38,13.3,28.55,20.04,42.1,20.04s27.72-6.74,42.11-20.04c4.86-4.5,12.46-4.21,16.95.66,4.5,4.87,4.21,12.46-.66,16.96ZM255.53,204.04c-19.1,17.67-38.75,26.5-58.4,26.5s-39.29-8.83-58.39-26.5c-14.39-13.3-28.55-20.04-42.11-20.04s-27.72,6.74-42.1,20.04c-4.87,4.5-12.46,4.21-16.96-.66s-4.2-12.46.67-16.96c38.2-35.32,78.59-35.32,116.79,0,14.38,13.3,28.55,20.05,42.1,20.05s27.72-6.75,42.11-20.05c4.86-4.5,12.46-4.2,16.95.67,4.5,4.86,4.21,12.45-.66,16.95Z"
/>
</svg>
<img
src={logoUrl}
alt=""
aria-hidden="true"
draggable="false"
class={className}
/>
<style>
img {
display: block;
object-fit: contain;
}
</style>
+25 -13
View File
@@ -98,11 +98,21 @@
};
let active: DeviceType = $state('phone');
let loaded: Record<DeviceType, boolean> = $state({
phone: true,
tablet: false,
desktop: false,
tv: false,
});
let scrollContainer: HTMLElement | undefined = $state();
let canScrollLeft = $state(false);
let canScrollRight = $state(false);
let intendedScrollLeft: number | undefined;
function selectDevice(device: DeviceType) {
loaded[device] = true;
active = device;
}
function updateScrollState() {
if (!scrollContainer) return;
if (intendedScrollLeft !== undefined && Math.abs(scrollContainer.scrollLeft - intendedScrollLeft) < 2) {
@@ -174,7 +184,7 @@
{@const DeviceIcon = device.icon}
<button
type="button"
onclick={() => active = device.id}
onclick={() => selectDevice(device.id)}
aria-pressed={active === device.id}
aria-controls={`screenshots-${device.id}-panel`}
aria-label={`Show ${device.label} screenshots`}
@@ -228,19 +238,21 @@
if (active === device.id) updateScrollState();
}}
>
{#each screenshot.shots as shot}
<div class="screenshot-item">
<div class={`screenshot-frame ${screenshot.frameClass}`}>
<enhanced:img
src={shot.image}
alt={shot.alt}
loading="eager"
class="screenshot-image"
sizes={screenshot.sizes}
/>
{#if loaded[device.id]}
{#each screenshot.shots as shot}
<div class="screenshot-item">
<div class={`screenshot-frame ${screenshot.frameClass}`}>
<enhanced:img
src={shot.image}
alt={shot.alt}
loading="lazy"
class="screenshot-image"
sizes={screenshot.sizes}
/>
</div>
</div>
</div>
{/each}
{/each}
{/if}
</div>
{/each}
</div>
+42 -12
View File
@@ -4,21 +4,49 @@
let { children, delay = 0, class: className = '' }: { children: Snippet; delay?: number; class?: string } = $props();
let el: HTMLDivElement | undefined = $state();
let visible = $state(false);
let visible = $state(true);
$effect(() => {
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
visible = true;
observer.disconnect();
}
},
{ threshold: 0.1 }
);
observer.observe(el);
return () => observer.disconnect();
visible = true;
if (
typeof IntersectionObserver === 'undefined' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
return;
}
const rect = el.getBoundingClientRect();
const hasGeometry = rect.width > 0 && rect.height > 0;
const isInViewport =
hasGeometry &&
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < window.innerHeight &&
rect.left < window.innerWidth;
if (!hasGeometry || isInViewport) return;
let observer: IntersectionObserver | undefined;
try {
observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
visible = true;
observer?.disconnect();
}
},
{ threshold: 0.1 }
);
observer.observe(el);
visible = false;
} catch {
observer?.disconnect();
visible = true;
return;
}
return () => observer?.disconnect();
});
</script>
@@ -39,7 +67,9 @@
@media (prefers-reduced-motion: reduce) {
.scroll-reveal {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}
</style>
+67
View File
@@ -0,0 +1,67 @@
export type MobileStorePlatform = 'ios' | 'android' | 'unknown';
export type PlatformEvidence = {
userAgent?: string;
platform?: string;
maxTouchPoints?: number;
};
export type StoreOption = {
id: 'app-store' | 'play-store';
label: string;
url: string;
};
export const storeOptions = {
ios: {
id: 'app-store',
label: 'App Store',
url: 'https://apps.apple.com/us/app/id6754315964',
},
android: {
id: 'play-store',
label: 'Google Play',
url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy',
},
} as const satisfies Record<'ios' | 'android', StoreOption>;
export function detectMobileStorePlatform(evidence: PlatformEvidence = {}): MobileStorePlatform {
const userAgent = evidence.userAgent?.toLowerCase() ?? '';
const platform = evidence.platform?.toLowerCase() ?? '';
if (/iphone|ipad|ipod/.test(userAgent)) return 'ios';
if (/android/.test(userAgent)) return 'android';
// iPadOS can request a desktop site and identify as MacIntel. Touch support
// distinguishes it from a Mac without guessing from screen dimensions.
if (platform === 'macintel' && (evidence.maxTouchPoints ?? 0) > 1) return 'ios';
return 'unknown';
}
export function storeOptionsForPlatform(platform: MobileStorePlatform): readonly StoreOption[] {
if (platform === 'ios') return [storeOptions.ios];
if (platform === 'android') return [storeOptions.android];
return [storeOptions.ios, storeOptions.android];
}
export const linuxArchitectures = [
{
label: 'x64 (Intel/AMD)',
formats: [
{ label: '.deb (Debian/Ubuntu)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb' },
{ label: '.rpm (Fedora/RHEL)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm' },
{ label: '.pkg.tar.zst (Arch)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst' },
{ label: '.tar.gz (Portable)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz' },
],
},
{
label: 'ARM64',
formats: [
{ label: '.deb (Debian/Ubuntu)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb' },
{ label: '.rpm (Fedora/RHEL)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm' },
{ label: '.pkg.tar.zst (Arch)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst' },
{ label: '.tar.gz (Portable)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz' },
],
},
] as const;
+4 -2
View File
@@ -1,3 +1,6 @@
export const watchTogetherFaqAnswer =
"Watch Together requires every participant to have access to the same media on the same server. It uses a WebSocket relay to exchange room and participant details, server and media identifiers, an optional media title, and playback timing/control state. It does not relay the media stream or your media-server credentials. Plezys relay is the default; if you choose a custom relay, that relays operator controls its security, logging, retention, and location.";
export type Faq = {
id: string;
question: string;
@@ -37,8 +40,7 @@ export const faqs: Faq[] = [
{
id: "watch-together",
question: "How does Watch Together work?",
answer:
"Watch Together uses a WebSocket relay to sync playback between users. The other person needs access to the same media on the same server. Only playback sync messages are exchanged - nothing about your server is shared.",
answer: watchTogetherFaqAnswer,
},
{
id: "video-player",
@@ -0,0 +1,59 @@
export type StorePrices = {
appStorePrice: string | null;
playStorePrice: string | null;
};
export type SoftwareApplicationOffer = {
'@type': 'Offer';
url: string;
category: string;
price?: string;
priceCurrency?: 'USD';
};
export function normalizeUsdStorePrice(value: unknown, currency: unknown): string | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || currency !== 'USD') return null;
return String(value);
}
export function buildSoftwareApplicationOffers({
appStorePrice,
playStorePrice,
}: StorePrices): SoftwareApplicationOffer[] {
return [
{
'@type': 'Offer',
url: 'https://apps.apple.com/us/app/id6754315964',
category: 'App Store',
...(appStorePrice === null
? {}
: {
price: appStorePrice,
priceCurrency: 'USD' as const,
}),
},
{
'@type': 'Offer',
url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy',
category: 'Google Play',
...(playStorePrice === null
? {}
: {
price: playStorePrice,
priceCurrency: 'USD' as const,
}),
},
{
'@type': 'Offer',
url: 'https://www.amazon.com/gp/product/B0GK65CVS1',
category: 'Amazon Appstore',
},
{
'@type': 'Offer',
url: 'https://github.com/edde746/plezy',
price: '0',
priceCurrency: 'USD',
category: 'GitHub',
},
];
}
+1 -1
View File
@@ -55,7 +55,7 @@
background: var(--color-surface-highest);
}
.error-logo :global(svg) {
.error-logo :global(img) {
width: 2.5rem;
height: 2.5rem;
}
+12 -8
View File
@@ -1,4 +1,5 @@
import type { PageServerLoad } from './$types';
import { normalizeUsdStorePrice } from '$lib/content/software_app_offers';
export const load: PageServerLoad = async ({ fetch }) => {
let appStoreRating: { score: number; count: number } | null = null;
@@ -8,6 +9,7 @@ export const load: PageServerLoad = async ({ fetch }) => {
try {
const res = await fetch('https://itunes.apple.com/lookup?id=6754315964');
if (!res.ok) throw new Error(`App Store lookup failed: HTTP ${res.status}`);
const data = await res.json();
const app = data.results?.[0];
if (app?.averageUserRating && app?.userRatingCount) {
@@ -16,25 +18,27 @@ export const load: PageServerLoad = async ({ fetch }) => {
count: app.userRatingCount
};
}
if (app?.price != null) {
appStorePrice = String(app.price);
}
appStorePrice = normalizeUsdStorePrice(app?.price, app?.currency);
} catch {
// App Store fetch failed, continue without it
}
try {
const gplay = await import('google-play-scraper');
const app = await gplay.default.app({ appId: 'com.edde746.plezy' });
// Module initialization is optional external data and must stay inside this failure boundary.
const { default: gplay } = await import('google-play-scraper');
const app = await gplay.app({
appId: 'com.edde746.plezy',
country: 'us',
lang: 'en'
});
if (app.available === false) throw new Error('Google Play listing unavailable');
if (app.score && app.ratings) {
playStoreRating = {
score: app.score,
count: app.ratings
};
}
if (app.price != null) {
playStorePrice = String(app.price);
}
playStorePrice = normalizeUsdStorePrice(app.price, app.currency);
} catch {
// Play Store fetch failed, continue without it
}
+5 -28
View File
@@ -6,6 +6,7 @@
import FAQ from '$lib/components/FAQ.svelte';
import Footer from '$lib/components/Footer.svelte';
import { faqSchemaMainEntity } from '$lib/content/faqs';
import { buildSoftwareApplicationOffers } from '$lib/content/software_app_offers';
const { data } = $props();
@@ -23,34 +24,10 @@
"url": "https://plezy.app",
"applicationCategory": "MultimediaApplication",
"operatingSystem": "iOS, Android, Android TV, tvOS, Windows, macOS, Linux",
"offers": [
{
"@type": "Offer",
"url": "https://apps.apple.com/us/app/id6754315964",
"price": data.appStorePrice ?? "0",
"priceCurrency": "USD",
"category": "App Store"
},
{
"@type": "Offer",
"url": "https://play.google.com/store/apps/details?id=com.edde746.plezy",
"price": data.playStorePrice ?? "0",
"priceCurrency": "USD",
"category": "Google Play"
},
{
"@type": "Offer",
"url": "https://www.amazon.com/gp/product/B0GK65CVS1",
"category": "Amazon Appstore"
},
{
"@type": "Offer",
"url": "https://github.com/edde746/plezy",
"price": "0",
"priceCurrency": "USD",
"category": "GitHub"
}
]
"offers": buildSoftwareApplicationOffers({
appStorePrice: data.appStorePrice,
playStorePrice: data.playStorePrice
})
};
if (data.aggregateRating) {
+116 -162
View File
@@ -1,22 +1,26 @@
<script lang="ts">
import Logo from '$lib/components/Logo.svelte';
const title = 'Privacy Policy - Plezy';
const description = 'How Plezy stores data on your device and shares data when you use connected services.';
const url = 'https://plezy.app/privacy';
</script>
<svelte:head>
<title>Privacy Policy - Plezy</title>
<meta name="description" content="Learn how Plezy handles your data when connecting to Plex and Jellyfin. Our privacy policy covers authentication, crash diagnostics, local network information, and data storage practices." />
<link rel="canonical" href="https://plezy.app/privacy" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Plezy" />
<meta property="og:title" content="Privacy Policy - Plezy" />
<meta property="og:description" content="Learn how Plezy handles your data. Our privacy policy covers authentication, crash diagnostics, local network information, and data storage practices." />
<meta property="og:url" content="https://plezy.app/privacy" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={url} />
<meta property="og:image" content="https://plezy.app/og/plezy-social.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Privacy Policy - Plezy" />
<meta name="twitter:description" content="Learn how Plezy handles your data. Our privacy policy covers authentication, crash diagnostics, local network information, and data storage practices." />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content="https://plezy.app/og/plezy-social.png" />
</svelte:head>
@@ -26,146 +30,115 @@
<span>Back to Plezy</span>
</a>
<h1 class="privacy-heading">Privacy Policy</h1>
<p class="last-updated">Last Updated: May 2, 2026</p>
<h1>Privacy Policy</h1>
<p class="last-updated">Last updated: July 24, 2026</p>
<div class="prose">
<h2>Introduction</h2>
<p>Plezy ("we", "our", or "the app") is a third-party client for Plex and Jellyfin that allows you to access and stream content from your media server. This privacy policy explains how we handle your information when you use our app.</p>
<section aria-labelledby="overview">
<h2 id="overview">Overview</h2>
<p>
Plezy is a third-party Plex and Jellyfin client. Most account, library, playback, streaming, and
download traffic travels directly between your device and the services or media servers you choose.
Plezy servers are not in the normal media-streaming path.
</p>
<p>
We do not sell personal data or use it for advertising. The Plezy website does not use analytics or
advertising trackers.
</p>
</section>
<h2>Information We Collect</h2>
<section aria-labelledby="device-data">
<h2 id="device-data">Data on your device</h2>
<p>
To provide its features, Plezy saves server addresses, access tokens and other sign-in data, profiles,
settings, integration sessions, playback state, downloads, and cached artwork on your device. Sensitive
credentials use platform-provided protected storage where available. TV versions may also publish
Continue Watching titles, progress, and artwork to the system home screen.
</p>
<p>
Downloads in a custom folder and copies kept by the operating system or a backup provider may remain
outside storage controlled by Plezy.
</p>
</section>
<h3>Authentication Information</h3>
<p>When you sign in to Plezy, we collect and process the following information depending on which service you connect:</p>
<ul>
<li>Plex: authentication tokens, account username, and server connection information</li>
<li>Jellyfin: server URL, access token, username, and user ID</li>
</ul>
<section aria-labelledby="connections">
<h2 id="connections">Connections and third parties</h2>
<p>
When you use a connected feature, Plezy sends the data needed for it to the relevant provider. This can
include authentication data, searches, library requests, media identifiers, playback state, and changes
you make. Providers can include Plex, the Jellyfin or Seerr server you select, TMDB, Trakt, Simkl,
MyAnimeList, AniList, Discord, GitHub, jsDelivr, and artwork hosts returned by those services. They also
receive ordinary network information such as your IP address and user agent, and handle data under their
own privacy terms.
</p>
<p>
Jellyfin and Companion Remote can send discovery traffic on your local network. If you use an external
player, that app receives the media URL, which may contain a server access credential, together with
playback details. TV home-screen features can automatically fetch artwork from your media server. A
server or relay you configure may use HTTP instead of HTTPS.
</p>
</section>
<h3>Crash and Diagnostic Data</h3>
<p>To help us identify and fix bugs, the app automatically collects:</p>
<ul>
<li>Crash reports and error stack traces</li>
<li>Application Not Responding (ANR) events</li>
<li>Device model, OS version, and app version</li>
<li>Diagnostic breadcrumbs (e.g. playback events leading up to a crash)</li>
</ul>
<p>This data is sent to our self-hosted error tracking server (bugs.plezy.app). Sensitive information such as authentication tokens and server URLs is automatically stripped before transmission.</p>
<section aria-labelledby="plezy-services">
<h2 id="plezy-services">Optional Plezy services</h2>
<p>
Plezy-hosted services are used for Watch Together, MyAnimeList and AniList sign-in handoff, temporary
Discord artwork, optional crash reporting, and support logs you explicitly upload. Watch Together
exchanges room, participant, server and media identifiers, and playback timing; it does not carry the
media stream or your media-server credentials. A custom relay is controlled by its operator.
</p>
<p>
Crash reports can include app, device, error, and recent diagnostic information. Support logs may still
contain sensitive details after automatic redaction, and anyone with the retrieval ID can access an
uploaded log while it is available. Discord artwork is available through a temporary public URL.
Plezy-hosted services also keep limited operational logs for security and reliability.
</p>
</section>
<h3>Local Network Information</h3>
<p>To connect to your media server on your local network, we may access:</p>
<ul>
<li>Local network device discovery information</li>
<li>IP addresses of media servers on your network</li>
</ul>
<section aria-labelledby="choices">
<h2 id="choices">Your choices</h2>
<p>
You can remove media-server and tracker connections, disable crash reporting, update checks, scrobbling,
Companion Remote, Discord Rich Presence, and external-player routing, or leave Watch Together. You can
delete downloads, clear the artwork cache, remove profiles, or delete app data. Support-log uploads are
always explicit. Removing local data does not remove copies already held by a connected service, custom
storage provider, external app, system home screen, or backup.
</p>
</section>
<h3>Usage Information</h3>
<p>The app stores locally on your device:</p>
<ul>
<li>Your authentication session</li>
<li>App preferences and settings</li>
<li>Recently accessed content history</li>
</ul>
<section aria-labelledby="retention-security">
<h2 id="retention-security">Retention and security</h2>
<p>
Local connection and profile data remains until you remove it or delete app data. Downloads have no
automatic expiry. Cached artwork is removed through cache maintenance or the Clear Cache action, and TV
home-screen data remains until a later update or clear succeeds. Plezy-hosted crash events are configured
for up to 90 days, support logs can be retrieved for three days, and Discord artwork is available for no
more than three hours. OAuth handoffs and inactive Watch Together rooms are short-lived. Other providers
set their own retention periods.
</p>
<p>
Plezy uses HTTPS for its hosted services and supported cloud providers, limits sensitive uploads, and
redacts known secrets from diagnostics. No system is completely secure, and user-configured servers,
relays, external players, storage locations, and platform backups remain under their respective
operators control.
</p>
</section>
<h2>How We Use Your Information</h2>
<p>We use the collected information solely to:</p>
<ul>
<li>Authenticate you with your Plex or Jellyfin account</li>
<li>Connect to and communicate with your media server</li>
<li>Display your media library and stream content</li>
<li>Maintain your session and app preferences</li>
</ul>
<h2>Data Storage and Security</h2>
<ul>
<li>Authentication tokens are stored securely on your device using platform-specific secure storage mechanisms</li>
<li>App preferences are stored locally on your device</li>
<li>Crash and diagnostic data is sent to our self-hosted error tracking server (bugs.plezy.app) &mdash; no third-party services receive this data</li>
<li>All other communication is directly between your device and your media server, or with Plex's authentication services when signing in with Plex</li>
</ul>
<h2>Third-Party Services</h2>
<h3>Plex</h3>
<p>Plezy uses Plex's authentication and media services. When you use this app:</p>
<ul>
<li>You authenticate directly with Plex's servers</li>
<li>Your media streaming occurs between your device and your Plex Media Server</li>
<li>Plex's own privacy policy applies to their services: <a href="https://www.plex.tv/about/privacy-legal/" target="_blank" rel="noopener noreferrer">plex.tv/about/privacy-legal</a></li>
</ul>
<p>We do not control and are not responsible for Plex's data practices.</p>
<h3>Jellyfin</h3>
<p>Plezy can connect to your self-hosted Jellyfin server. When you use this app with Jellyfin:</p>
<ul>
<li>You authenticate directly with your Jellyfin server using your username and password, or via Quick Connect</li>
<li>Your media streaming occurs between your device and your Jellyfin server</li>
<li>No third party (including Plezy) is involved in Jellyfin authentication or playback</li>
</ul>
<p>Because Jellyfin is self-hosted, your Jellyfin server's privacy practices are determined by whoever operates it. More information about the Jellyfin project is available at <a href="https://jellyfin.org" target="_blank" rel="noopener noreferrer">jellyfin.org</a>.</p>
<h3>Litterbox (Discord Rich Presence)</h3>
<p>When Discord Rich Presence is enabled, media artwork may be temporarily uploaded to:</p>
<ul>
<li>Litterbox (litterbox.catbox.moe) &mdash; a temporary file hosting service</li>
</ul>
<p>These uploads are necessary because Discord requires publicly accessible image URLs for Rich Presence artwork. Uploaded images automatically expire after 1 hour. This feature is opt-in and disabled by default.</p>
<h2>Data Sharing</h2>
<p>We do not:</p>
<ul>
<li>Sell your personal information</li>
<li>Share your information with third parties for marketing purposes</li>
<li>Transmit your data to our own servers (except crash/diagnostic reports as described above)</li>
<li>Track your viewing habits outside of local app functionality</li>
</ul>
<p>Your data is only shared with:</p>
<ul>
<li>Plex services for authentication and media server communication, when signing in with Plex (as required for the app to function)</li>
<li>Your self-hosted Jellyfin server, when signing in with Jellyfin (as required for the app to function)</li>
</ul>
<h2>Your Rights and Choices</h2>
<p>You can:</p>
<ul>
<li>Sign out at any time to remove your authentication session from the device</li>
<li>Uninstall the app to remove all locally stored data</li>
<li>Manage your Plex account directly through Plex's services, or your Jellyfin account through your Jellyfin server</li>
</ul>
<h2>Children's Privacy</h2>
<p>Plezy does not knowingly collect personal information from children under 13. The app relies on authentication with Plex or Jellyfin servers, and users must comply with the terms of service and age requirements of whichever service they connect to.</p>
<h2>Changes to This Privacy Policy</h2>
<p>We may update this privacy policy from time to time. We will notify users of any material changes by updating the "Last Updated" date at the top of this policy.</p>
<h2>Data Retention</h2>
<ul>
<li>Authentication tokens are retained on your device until you sign out or uninstall the app</li>
<li>Local preferences and settings are retained until you uninstall the app</li>
<li>Crash and diagnostic reports are retained on our self-hosted server and automatically pruned based on relevance</li>
</ul>
<h2>International Data Transfers</h2>
<p>As we do not operate backend servers, there are no international data transfers from our side. Any data transfers occur directly between your device and either Plex's services (subject to Plex's privacy policy) or your Jellyfin server (governed by whoever operates it).</p>
<h2>Legal Compliance</h2>
<p>This app complies with:</p>
<ul>
<li>General Data Protection Regulation (GDPR)</li>
<li>California Consumer Privacy Act (CCPA)</li>
<li>Children's Online Privacy Protection Act (COPPA)</li>
<li>Other applicable privacy laws</li>
</ul>
<h2>Your Consent</h2>
<p>By using Plezy, you consent to this privacy policy and the processing of your information as described herein.</p>
<section aria-labelledby="contact">
<h2 id="contact">Contact and changes</h2>
<p>
For privacy questions or requests, contact us through the
<a href="https://github.com/edde746/plezy" target="_blank" rel="noopener noreferrer">Plezy GitHub project</a>.
GitHub activity is public, so do not include passwords, access tokens, support-log IDs, or other sensitive
information. Material changes to this policy will be posted here with a new date.
</p>
</section>
</div>
</article>
<style>
.privacy-article {
width: min(100%, 58rem);
width: min(100%, 48rem);
margin-inline: auto;
padding: clamp(2rem, 7vw, 6rem) var(--page-gutter) clamp(5rem, 10vw, 9rem);
}
@@ -194,12 +167,12 @@
outline: none;
}
.back-logo :global(svg) {
.back-logo :global(img) {
width: 1.375rem;
height: 1.375rem;
}
.privacy-heading {
h1 {
margin-bottom: 0.75rem;
font-family: var(--font-display);
font-size: clamp(2.75rem, 9vw, 5rem);
@@ -216,14 +189,16 @@
}
.prose {
max-width: 48rem;
color: var(--color-text-muted);
font-size: 1rem;
line-height: 1.8;
}
.prose > section + section {
margin-top: 3rem;
}
.prose h2 {
margin-top: 3.5rem;
margin-bottom: 1rem;
color: var(--color-text);
font-family: var(--font-display);
@@ -233,29 +208,8 @@
line-height: 1.2;
}
.prose h3 {
margin-top: 2rem;
margin-bottom: 0.625rem;
color: var(--color-text);
font-family: var(--font-display);
font-size: 1.0625rem;
font-weight: 700;
}
.prose p {
margin-bottom: 1.125rem;
}
.prose ul {
margin-bottom: 1.25rem;
border-radius: var(--radius-lg);
padding: 1.25rem 1.25rem 1.25rem 2.75rem;
background: var(--color-surface);
list-style: disc;
}
.prose li + li {
margin-top: 0.5rem;
.prose p + p {
margin-top: 1.125rem;
}
.prose a {
+2
View File
@@ -0,0 +1,2 @@
export const csr = false;
export const prerender = true;
+26 -31
View File
@@ -1,21 +1,24 @@
<script lang="ts">
import { browser } from "$app/environment";
import { onMount } from "svelte";
import Logo from "$lib/components/Logo.svelte";
import AppleIcon from "~icons/simple-icons/apple";
import GooglePlayIcon from "~icons/simple-icons/googleplay";
import {
detectMobileStorePlatform,
storeOptionsForPlatform,
type MobileStorePlatform,
} from "$lib/content/downloads";
type Platform = "ios" | "android" | "unknown";
let platform: MobileStorePlatform = $state("unknown");
let availableStores = $derived(storeOptionsForPlatform(platform));
let platform: Platform = $state("unknown");
if (browser) {
const ua = navigator.userAgent.toLowerCase();
if (/iphone|ipad|ipod/.test(ua)) {
platform = "ios";
} else if (/android/.test(ua)) {
platform = "android";
}
}
onMount(() => {
platform = detectMobileStorePlatform({
userAgent: navigator.userAgent,
platform: navigator.platform,
maxTouchPoints: navigator.maxTouchPoints,
});
});
</script>
<svelte:head>
@@ -44,30 +47,22 @@
<h1 class="scan-heading">Scan in Plezy</h1>
<p class="scan-description">To use this feature, scan this QR code with the Plezy app.</p>
<div class="store-buttons">
{#if platform !== "android"}
<div class="store-buttons" aria-label="Download Plezy">
{#each availableStores as store}
<a
href="https://apps.apple.com/us/app/id6754315964"
href={store.url}
target="_blank"
rel="noopener noreferrer"
class="store-button"
>
<AppleIcon />
App Store
{#if store.id === "app-store"}
<AppleIcon />
{:else}
<GooglePlayIcon />
{/if}
{store.label}
</a>
{/if}
{#if platform !== "ios"}
<a
href="https://play.google.com/store/apps/details?id=com.edde746.plezy"
target="_blank"
rel="noopener noreferrer"
class="store-button"
>
<GooglePlayIcon />
Google Play
</a>
{/if}
{/each}
</div>
</div>
</div>
@@ -103,7 +98,7 @@
background: var(--color-surface-highest);
}
.scan-logo :global(svg) {
.scan-logo :global(img) {
width: 2.5rem;
height: 2.5rem;
}
+61
View File
@@ -0,0 +1,61 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { createServer, type ViteDevServer } from 'vite';
let vite: ViteDevServer;
beforeAll(async () => {
vite = await createServer({
root: process.cwd(),
appType: 'custom',
server: { middlewareMode: true },
});
}, 30_000);
afterAll(async () => {
await vite?.close();
}, 30_000);
async function renderComponent(path: string): Promise<string> {
const [{ default: component }, { render: renderOnViteGraph }] = await Promise.all([
vite.ssrLoadModule(path),
vite.ssrLoadModule('svelte/server'),
]);
return renderOnViteGraph(component).body;
}
describe('SSR component contracts', () => {
test('Logo renders the one managed asset as a decorative non-draggable image', async () => {
const html = await renderComponent('/src/lib/components/Logo.svelte');
expect(html).toContain('<img');
expect(html).toContain('alt=""');
expect(html).toContain('aria-hidden="true"');
expect(html).toContain('draggable="false"');
expect(html).not.toContain('<path');
}, 30_000);
test('Linux downloads prerender as an ordinary inert disclosure', async () => {
const html = await renderComponent('/src/lib/components/DownloadButtons.svelte');
expect(html).toContain('aria-expanded="false"');
expect(html).toContain('aria-controls="');
expect(html).toContain('aria-hidden="true"');
expect(html).toContain('inert');
expect(html).not.toContain('aria-haspopup');
expect(html).not.toContain('role="menu"');
expect(html).not.toContain('role="menuitem"');
expect(html.match(/plezy-linux-(?:x64|arm64)\.(?:deb|rpm|pkg\.tar\.zst|tar\.gz)/g)).toHaveLength(8);
}, 30_000);
test('Screenshots prerender stable regions but only the initial lazy phone images', async () => {
const html = await renderComponent('/src/lib/components/Screenshots.svelte');
for (const device of ['phone', 'tablet', 'desktop', 'tv']) {
expect(html).toContain(`id="screenshots-${device}-panel"`);
}
expect(html.match(/<picture\b/g)).toHaveLength(4);
expect(html.match(/loading="lazy"/g)).toHaveLength(4);
expect(html.match(/width="\d+" height="\d+"/g)).toHaveLength(4);
expect(html).toContain('alt="Plezy home screen"');
expect(html).toContain('style="opacity: 1; transform: translateY(0px);');
expect(html).not.toContain('alt="Plezy on tablet - home"');
expect(html).not.toContain('alt="Plezy on desktop - home"');
expect(html).not.toContain('alt="Plezy on TV - home"');
}, 30_000);
});
+95
View File
@@ -0,0 +1,95 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { createServer, type ViteDevServer } from 'vite';
import { faqs, faqSchemaMainEntity, watchTogetherFaqAnswer } from '../src/lib/content/faqs';
import { csr, prerender } from '../src/routes/privacy/+page';
let vite: ViteDevServer;
let renderedPrivacyHead: string;
let renderedPrivacyHtml: string;
let privacySource: string;
beforeAll(async () => {
vite = await createServer({
root: process.cwd(),
appType: 'custom',
server: { middlewareMode: true },
});
const [{ default: privacyPage }, { render }] = await Promise.all([
vite.ssrLoadModule('/src/routes/privacy/+page.svelte'),
vite.ssrLoadModule('svelte/server'),
]);
const rendered = render(privacyPage);
renderedPrivacyHead = rendered.head;
renderedPrivacyHtml = rendered.body;
privacySource = await Bun.file('src/routes/privacy/+page.svelte').text();
}, 90_000);
afterAll(async () => {
await vite?.close();
}, 30_000);
function visibleText(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replaceAll('&amp;', '&')
.replaceAll('&#39;', "'")
.replace(/\s+/g, ' ')
.trim();
}
describe('privacy page', () => {
test('remains a prerendered, server-rendered route', () => {
expect(prerender).toBe(true);
expect(csr).toBe(false);
expect(renderedPrivacyHead).toContain('<title>Privacy Policy - Plezy</title>');
expect(renderedPrivacyHead).toContain('<link rel="canonical" href="https://plezy.app/privacy"');
});
test('renders a concise semantic policy without inventory cards or grids', () => {
for (const heading of [
'Overview',
'Data on your device',
'Connections and third parties',
'Optional Plezy services',
'Your choices',
'Retention and security',
'Contact and changes',
]) {
expect(renderedPrivacyHtml).toContain(`>${heading}</h2>`);
}
expect(renderedPrivacyHtml.match(/<section/g)).toHaveLength(7);
expect(renderedPrivacyHtml).not.toContain('flow-card');
expect(renderedPrivacyHtml).not.toContain('<dl');
expect(renderedPrivacyHtml).not.toContain('<ul');
expect(privacySource).not.toContain('display: grid');
expect(visibleText(renderedPrivacyHtml).split(' ').length).toBeLessThan(900);
});
test('uses first-party language and preserves the material privacy boundaries', () => {
const text = visibleText(renderedPrivacyHtml);
expect(text).not.toMatch(/\brepositor(?:y|ies)\b/i);
expect(text).not.toMatch(/repository-verified/i);
expect(text).toContain('We do not sell personal data or use it for advertising');
expect(text).toContain('access tokens and other sign-in data');
expect(text).toContain('may contain a server access credential');
expect(text).toContain('does not carry the media stream or your media-server credentials');
expect(text).toContain('Support-log uploads are always explicit');
expect(text).toContain('up to 90 days');
expect(text).toContain('retrieved for three days');
expect(text).toContain('no more than three hours');
expect(renderedPrivacyHtml).toContain('rel="noopener noreferrer"');
});
test('keeps the Watch Together disclosure aligned with visible FAQ content', () => {
const faqIndex = faqs.findIndex((faq) => faq.id === 'watch-together');
expect(faqIndex).toBeGreaterThanOrEqual(0);
expect(faqs[faqIndex]!.answer).toBe(watchTogetherFaqAnswer);
expect(faqSchemaMainEntity[faqIndex]!.acceptedAnswer.text).toBe(watchTogetherFaqAnswer);
expect(watchTogetherFaqAnswer).toContain('server and media identifiers');
expect(watchTogetherFaqAnswer).toContain('does not relay the media stream');
expect(watchTogetherFaqAnswer).toContain('custom relay');
});
});
+168
View File
@@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import {
detectMobileStorePlatform,
linuxArchitectures,
storeOptionsForPlatform,
} from '../src/lib/content/downloads';
import {
buildSoftwareApplicationOffers,
normalizeUsdStorePrice,
} from '../src/lib/content/software_app_offers';
type PlayStoreFixture = {
available?: boolean;
price?: unknown;
currency?: unknown;
score?: number;
ratings?: number;
};
let playStoreFixture: PlayStoreFixture | Error;
mock.module('google-play-scraper', () => ({
default: {
app: async () => {
if (playStoreFixture instanceof Error) throw playStoreFixture;
return playStoreFixture;
},
},
}));
// Dynamic loading is intentional here: Bun must install the scraper mock before
// the route first loads its optional external dependency.
const { load } = await import('../src/routes/+page.server');
function appleFetch(body: unknown, status = 200): typeof fetch {
return (async () => new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})) as typeof fetch;
}
async function loadHomepage(fetcher: typeof fetch) {
return await (load as (event: { fetch: typeof fetch }) => Promise<{
appStorePrice: string | null;
playStorePrice: string | null;
aggregateRating: { ratingValue: string; ratingCount: number } | null;
}>)({ fetch: fetcher });
}
beforeEach(() => {
playStoreFixture = {
available: true,
price: 4.99,
currency: 'USD',
score: 4.5,
ratings: 10,
};
});
afterEach(() => {
mock.restore();
});
describe('download component contracts', () => {
test('detects mobile stores without browser globals', () => {
expect(detectMobileStorePlatform()).toBe('unknown');
expect(detectMobileStorePlatform({ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)' })).toBe('ios');
expect(detectMobileStorePlatform({ userAgent: 'Mozilla/5.0 (Linux; Android 15)' })).toBe('android');
expect(detectMobileStorePlatform({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
platform: 'MacIntel',
maxTouchPoints: 5,
})).toBe('ios');
expect(detectMobileStorePlatform({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
platform: 'MacIntel',
maxTouchPoints: 0,
})).toBe('unknown');
});
test('unknown platforms retain both truthful store choices', () => {
expect(storeOptionsForPlatform('unknown').map((option) => option.label)).toEqual(['App Store', 'Google Play']);
expect(storeOptionsForPlatform('ios').map((option) => option.label)).toEqual(['App Store']);
expect(storeOptionsForPlatform('android').map((option) => option.label)).toEqual(['Google Play']);
});
test('Linux disclosure data contains both architectures and eight unique native links', () => {
expect(linuxArchitectures.map((architecture) => architecture.label)).toEqual(['x64 (Intel/AMD)', 'ARM64']);
const links = linuxArchitectures.flatMap((architecture) => architecture.formats.map((format) => format.url));
expect(links).toHaveLength(8);
expect(new Set(links).size).toBe(8);
expect(links.every((url) => url.startsWith('https://github.com/edde746/plezy/releases/latest/download/'))).toBe(true);
});
});
describe('homepage store metadata', () => {
test('normalizes only finite nonnegative numeric USD prices', () => {
expect(normalizeUsdStorePrice(4.99, 'USD')).toBe('4.99');
expect(normalizeUsdStorePrice(0, 'USD')).toBe('0');
for (const value of [null, undefined, '4.99', Number.NaN, Number.POSITIVE_INFINITY, -1]) {
expect(normalizeUsdStorePrice(value, 'USD')).toBeNull();
}
expect(normalizeUsdStorePrice(4.99, undefined)).toBeNull();
expect(normalizeUsdStorePrice(4.99, 'EUR')).toBeNull();
});
test('keeps Google Play metadata when the App Store request fails', async () => {
const data = await loadHomepage((async () => {
throw new Error('offline');
}) as typeof fetch);
expect(data.appStorePrice).toBeNull();
expect(data.playStorePrice).toBe('4.99');
expect(data.aggregateRating).toEqual({ ratingValue: '4.5', ratingCount: 10 });
});
test('keeps App Store metadata when Google Play is unavailable', async () => {
playStoreFixture = { available: false, price: 0, currency: 'USD' };
const data = await loadHomepage(appleFetch({
results: [{ price: 5.99, currency: 'USD', averageUserRating: 4, userRatingCount: 20 }],
}));
expect(data.appStorePrice).toBe('5.99');
expect(data.playStorePrice).toBeNull();
expect(data.aggregateRating).toEqual({ ratingValue: '4.0', ratingCount: 20 });
});
test('keeps App Store metadata when Google Play throws', async () => {
playStoreFixture = new Error('scraper failed');
const data = await loadHomepage(appleFetch({
results: [{ price: 5.99, currency: 'USD' }],
}));
expect(data.appStorePrice).toBe('5.99');
expect(data.playStorePrice).toBeNull();
});
test('rejects non-OK and malformed store responses independently', async () => {
playStoreFixture = { price: 'free', currency: 'USD' };
const data = await loadHomepage(appleFetch({ results: [{ price: 4.99, currency: 'USD' }] }, 503));
expect(data.appStorePrice).toBeNull();
expect(data.playStorePrice).toBeNull();
});
test('retains paid store URLs when live prices are unavailable', () => {
const unavailable = buildSoftwareApplicationOffers({ appStorePrice: null, playStorePrice: null });
expect(unavailable.map((offer) => offer.category)).toEqual([
'App Store',
'Google Play',
'Amazon Appstore',
'GitHub',
]);
expect(unavailable.find((offer) => offer.category === 'App Store')).toMatchObject({
url: 'https://apps.apple.com/us/app/id6754315964',
});
expect(unavailable.find((offer) => offer.category === 'Google Play')).toMatchObject({
url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy',
});
expect(unavailable.filter((offer) => offer.price === '0').map((offer) => offer.category)).toEqual(['GitHub']);
expect(
unavailable
.filter((offer) => ['App Store', 'Google Play'].includes(offer.category))
.every((offer) => !('price' in offer)),
).toBe(true);
const available = buildSoftwareApplicationOffers({ appStorePrice: '5.99', playStorePrice: '4.99' });
expect(available.find((offer) => offer.category === 'App Store')).toMatchObject({ price: '5.99', priceCurrency: 'USD' });
expect(available.find((offer) => offer.category === 'Google Play')).toMatchObject({ price: '4.99', priceCurrency: 'USD' });
});
});
+2 -1
View File
@@ -11,7 +11,8 @@
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
},
"exclude": ["tests"]
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//