Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/Icon.astro
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ const ICONS: Record<string, string> = {
layers:
'<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
'shield-check': '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
'alert-triangle':
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/>',
'check-circle': '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
wrench: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
cpu: '<rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2M15 20v2M2 15h2M2 9h2M20 15h2M20 9h2M9 2v2M9 20v2"/>',
sparkles:
Expand Down
69 changes: 69 additions & 0 deletions src/lib/compat-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Browser entry for the plugin compatibility pill.
*
* The server renders the pill from the request `User-Agent` so the layout is
* final on first paint. This re-runs the same check against
* `navigator.userAgent` and corrects the pill if the two disagree — the usual
* cause being iPadOS in desktop mode, which reports a Macintosh User-Agent but
* is really iOS.
*
* Corrections only ever toggle `hidden` on nodes that are always in the DOM, so
* a correction cannot change the element's height and shift the layout.
*
* This module touches the DOM, so it is only imported from `<script>` blocks
* (which Vite bundles for the client); it must never be imported from server code.
*/

import { compatibilityFor, detectOS, supportedLabel, type Compatibility } from './detectOS.ts';

/** Base shell classes, kept in sync with the server-rendered markup. */
const SHELL_BASE = 'rounded-xl border p-4';
const SHELL_VARIANT: Record<Exclude<Compatibility, 'unknown'>, string> = {
compatible: 'border-emerald-500/40 bg-emerald-500/10 shadow-[0_0_25px_rgba(16,185,129,0.15)]',
incompatible: 'border-amber-500/40 bg-amber-500/10 shadow-[0_0_25px_rgba(245,158,11,0.12)]',
};

function setHidden(element: Element | null, hidden: boolean): void {
if (element) element.classList.toggle('hidden', hidden);
}

function apply(root: HTMLElement, state: Compatibility, os: string, platforms: string[]): void {
root.dataset.compatState = state;

const shell = root.querySelector<HTMLElement>('[data-compat-shell]');
if (shell) {
shell.className =
state === 'unknown' ? `${SHELL_BASE} hidden` : `${SHELL_BASE} ${SHELL_VARIANT[state]}`;
}

setHidden(root.querySelector('[data-compat-icon="compatible"]'), state !== 'compatible');
setHidden(root.querySelector('[data-compat-icon="incompatible"]'), state !== 'incompatible');
setHidden(root.querySelector('[data-compat-supported]'), state !== 'incompatible');
// Sits below the action buttons, outside this root, so it is matched globally.
setHidden(document.querySelector('[data-compat-download-anyway]'), state !== 'incompatible');

const text = root.querySelector<HTMLElement>('[data-compat-text]');
if (text) {
text.textContent =
state === 'compatible'
? `✅ Compatible with your system (${os})`
: state === 'incompatible'
? `⚠️ Not compatible with your current OS (${os})`
: '';
text.classList.toggle('text-emerald-300', state === 'compatible');
text.classList.toggle('text-amber-300', state === 'incompatible');
}

const supported = root.querySelector<HTMLElement>('[data-compat-platforms]');
if (supported) supported.textContent = supportedLabel(platforms);
}

export function initCompatibility(root: HTMLElement): void {
const platforms = (root.dataset.platforms ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);

const os = detectOS(navigator.userAgent, { maxTouchPoints: navigator.maxTouchPoints });
apply(root, compatibilityFor(os, platforms), os, platforms);
}
105 changes: 105 additions & 0 deletions src/lib/detectOS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Visitor OS detection and plugin compatibility.
*
* The same pure functions run in two places:
* - the server, against the request's `User-Agent`, so the compatibility pill
* is already correct on first paint (no layout shift), and
* - the browser, against `navigator.userAgent`, as the authoritative check.
*
* Keeping one implementation means the two can never disagree about what a UA
* string means.
*/

export type DetectedOS = 'Windows' | 'macOS' | 'Linux' | 'Android' | 'iOS' | 'Unknown';

/**
* `compatible` / `incompatible` are claims we can defend from a UA string.
* `unknown` is the honest answer when we cannot tell — the caller renders
* nothing rather than guessing.
*/
export type Compatibility = 'compatible' | 'incompatible' | 'unknown';

/** A plugin tagged with this runs everywhere; it is a storefront convention, not a real OS. */
export const ALL_PLATFORMS = 'All Platforms';

/**
* The storefront label each OS matches in `platforms_json`.
* `null` means the storefront never publishes a build for that platform, so a
* plugin can never be compatible with it (iOS ships no `.orplug` binaries).
*/
const PLATFORM_LABEL: Record<DetectedOS, string | null> = {
Windows: 'Windows',
macOS: 'macOS',
Linux: 'Linux',
Android: 'Android',
iOS: null,
Unknown: null,
};

export interface DetectOptions {
/**
* `navigator.maxTouchPoints`. iPadOS 13+ reports a desktop `Macintosh`
* User-Agent, so the only client-side tell is a touch-capable "Mac". The
* server cannot see this, which is exactly why the browser re-checks.
*/
maxTouchPoints?: number;
}

/**
* Classify a User-Agent string. Order matters: an Android UA also contains
* "Linux", and ChromeOS contains "Linux" too, so the more specific tokens are
* tested first.
*/
export function detectOS(
userAgent: string | null | undefined,
options: DetectOptions = {},
): DetectedOS {
const ua = (userAgent ?? '').toLowerCase();
if (!ua) return 'Unknown';

if (ua.includes('android')) return 'Android';
if (/(iphone|ipod)/.test(ua)) return 'iOS';
// An explicit "iPad" UA (older iPadOS) also contains "like Mac OS X", so it
// must be tested before the Mac branch below.
if (ua.includes('ipad')) return 'iOS';

const looksLikeMac = ua.includes('mac os x') || ua.includes('macintosh');
if (looksLikeMac) {
// An iPad in desktop mode (iPadOS 13+) reports a desktop Safari UA with no
// "iPad" token; a touch-capable "Macintosh" is the only tell.
if ((options.maxTouchPoints ?? 0) > 1) return 'iOS';
return 'macOS';
}

if (ua.includes('windows')) return 'Windows';
// ChromeOS UAs end in "CrOS ... Linux", so treat them as Linux desktops.
if (ua.includes('linux') || ua.includes('x11') || ua.includes('cros')) return 'Linux';

return 'Unknown';
}

/**
* Decide whether a plugin supports the detected OS.
*
* An empty platform list means we have no compatibility data to stand on, so we
* say `unknown` instead of implying the plugin works everywhere.
*/
export function compatibilityFor(
detected: DetectedOS,
platforms: readonly string[] | null | undefined,
): Compatibility {
if (detected === 'Unknown') return 'unknown';

const list = (platforms ?? []).filter((platform): platform is string => Boolean(platform));
if (list.length === 0) return 'unknown';
if (list.includes(ALL_PLATFORMS)) return 'compatible';

const label = PLATFORM_LABEL[detected];
if (!label) return 'incompatible';
return list.includes(label) ? 'compatible' : 'incompatible';
}

/** Human-readable "Only available on: …" list, in the order the plugin declares. */
export function supportedLabel(platforms: readonly string[] | null | undefined): string {
return (platforms ?? []).filter(Boolean).join(', ');
}
91 changes: 91 additions & 0 deletions src/pages/plugin/[id].astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
orskinFileName,
} from '../../lib/theme';
import { renderMarkdown } from '../../lib/markdown';
import { compatibilityFor, detectOS, supportedLabel } from '../../lib/detectOS';

export const prerender = false;

Expand Down Expand Up @@ -80,6 +81,14 @@ const manifest = plugin
}
: null;

// Compatibility is decided from the request's User-Agent so the pill is correct
// on first paint. The client re-checks against navigator.userAgent and corrects
// it (iPadOS in desktop mode reports a Macintosh UA), which is why the server
// result is only a starting point.
const detectedOS = detectOS(Astro.request.headers.get('user-agent'));
const compatibility = plugin ? compatibilityFor(detectedOS, plugin.platforms) : 'unknown';
const supportedText = plugin ? supportedLabel(plugin.platforms) : '';

const heading = dbError ? 'Plugin unavailable' : 'Plugin not found';
const displayHeading = dbError
? 'Plugin unavailable'
Expand Down Expand Up @@ -188,6 +197,68 @@ const body = dbError
</div>
</div>

{/* Live OS compatibility. The server renders it from the request
User-Agent so it is correct on first paint; the client re-checks
and only toggles `hidden` on whole nodes, so a correction can never
shift the layout. */}
<div
class="mt-6"
data-compat
data-compat-state={compatibility}
data-platforms={plugin.platforms.join(',')}
>
<div
data-compat-shell
class:list={[
'rounded-xl border p-4',
compatibility === 'compatible' &&
'border-emerald-500/40 bg-emerald-500/10 shadow-[0_0_25px_rgba(16,185,129,0.15)]',
compatibility === 'incompatible' &&
'border-amber-500/40 bg-amber-500/10 shadow-[0_0_25px_rgba(245,158,11,0.12)]',
compatibility === 'unknown' && 'hidden',
]}
>
<div class="flex items-start gap-2.5">
<span
data-compat-icon="compatible"
class:list={['shrink-0', compatibility !== 'compatible' && 'hidden']}
>
<Icon name="check-circle" class="h-5 w-5 text-emerald-400" />
</span>
<span
data-compat-icon="incompatible"
class:list={['shrink-0', compatibility !== 'incompatible' && 'hidden']}
>
<Icon name="alert-triangle" class="h-5 w-5 text-amber-400" />
</span>
<div class="min-w-0">
<p
data-compat-text
class:list={[
'text-sm font-medium',
compatibility === 'compatible' && 'text-emerald-300',
compatibility === 'incompatible' && 'text-amber-300',
]}
>
{compatibility === 'compatible'
? `✅ Compatible with your system (${detectedOS})`
: compatibility === 'incompatible'
? `⚠️ Not compatible with your current OS (${detectedOS})`
: ''}
</p>

<p
data-compat-supported
class:list={['mt-2 text-sm text-zinc-400', compatibility !== 'incompatible' && 'hidden']}
>
Only available on:{' '}
<span class="font-medium text-zinc-300" data-compat-platforms>{supportedText}</span>
</p>
</div>
</div>
</div>
</div>

{/* Actions */}
<div class="mt-6 flex flex-wrap items-center gap-3">
{published ? (
Expand Down Expand Up @@ -254,6 +325,22 @@ const body = dbError
)
}

{/* The download stays available when the visitor's OS is not supported,
but the label says what they are getting so it is a deliberate choice. */}
<p
data-compat-download-anyway
class:list={[
'mt-3 text-xs text-zinc-500',
compatibility !== 'incompatible' && 'hidden',
]}
>
Downloading anyway?{' '}
<a href={`/api/download/${plugin.id}`} class="text-amber-400 hover:underline">
Download anyway for another PC
</a>
.
</p>

{/* Quick facts */}
<dl class="mt-6 flex flex-wrap items-center gap-x-6 gap-y-3 border-t border-zinc-800/70 pt-5 text-xs text-zinc-500">
<div class="flex items-center gap-1.5">
Expand Down Expand Up @@ -605,6 +692,10 @@ const body = dbError
</div>
</main>
<script>
import { initCompatibility } from '../../lib/compat-client';

document.querySelectorAll<HTMLElement>('[data-compat]').forEach(initCompatibility);

document.addEventListener('click', async (event) => {
const trigger = (event.target as HTMLElement).closest<HTMLElement>('[data-copy]');
if (!trigger) return;
Expand Down
Loading
Loading