aidevops 3.29.21 → 3.29.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ import type { GuiAppActionId, GuiAppActionJobSummary, GuiManagedAppSummary, GuiResponseEnvelope } from "@aidevops/gui-shared";
2
+ import { type ReactElement, useState } from "react";
3
+ import { FiChevronDown, FiDownload, FiExternalLink, FiRefreshCw, FiRepeat, FiTrash2 } from "react-icons/fi";
4
+ import { AppActionTerminal } from "./AppActionTerminal";
5
+ import { text } from "./app-model";
6
+ import { openExternalLink } from "./external-links";
7
+
8
+ type ManagedPolicyId = "setup" | "update";
9
+
10
+ export function ManagedAppPanel({ app, dismissedJobIds, expanded, job, onDismissJob, onJob, onPolicyToggle, onToggle, policyJobs }: { app: GuiManagedAppSummary; dismissedJobIds: Set<string>; expanded: boolean; job: GuiAppActionJobSummary | null; onDismissJob: (jobId: string) => void; onJob: (job: GuiAppActionJobSummary) => void; onPolicyToggle: (policy: ManagedPolicyId, value: boolean) => void; onToggle: () => void; policyJobs: GuiAppActionJobSummary[] }): ReactElement {
11
+ const lockedPolicy = isEssentialManagedApp(app);
12
+ const visibleJob = job === null || dismissedJobIds.has(job.id) ? null : job;
13
+ const visiblePolicyJobs = policyJobs.filter((policyJob) => !dismissedJobIds.has(policyJob.id));
14
+
15
+ return (
16
+ <article className={expanded ? "managed-app-card expanded" : "managed-app-card"}>
17
+ <button aria-expanded={expanded} className="managed-app-summary" data-tooltip={`${expanded ? "Collapse" : "Expand"} ${app.name} controls`} onClick={onToggle} type="button">
18
+ <span className="managed-app-title-block">
19
+ <span className="eyebrow">{app.category}</span>
20
+ <strong>{app.name}</strong>
21
+ <span>{app.description}</span>
22
+ </span>
23
+ <span className="managed-app-summary-meta">
24
+ <SummaryChip label="Installed" value={app.installed_version} />
25
+ <SummaryChip label="Latest" value={app.latest_version} />
26
+ <FiChevronDown aria-hidden="true" className="managed-app-chevron" />
27
+ </span>
28
+ </button>
29
+ {expanded ? <div className="managed-app-body">
30
+ <div className="managed-app-toolbar">
31
+ <div className="managed-app-links">
32
+ <OriginLink href={app.origin_website_url} label={text.website} />
33
+ <OriginLink href={app.origin_repo_url} label="Repo" />
34
+ </div>
35
+ <div className="managed-app-actions">
36
+ {app.actions.map((action) => <AppActionButton action={action.id} app={app} disabled={!action.enabled} key={action.id} onJob={onJob} commandPreview={action.command_preview} />)}
37
+ </div>
38
+ </div>
39
+ <div className="managed-app-details">
40
+ <ToggleSwitch checked={app.aidevops_install} disabled={lockedPolicy} label="setup installs" onChange={(value) => onPolicyToggle("setup", value)} />
41
+ <ToggleSwitch checked={app.aidevops_update} disabled={lockedPolicy} label="update maintains" onChange={(value) => onPolicyToggle("update", value)} />
42
+ {visibleJob ? <AppLogLink job={visibleJob} /> : null}
43
+ </div>
44
+ <AppMeta className="managed-app-path" label={text.path} value={app.install_path_ref} />
45
+ {visibleJob ? <AppActionTerminal job={visibleJob} onDismiss={onDismissJob} /> : null}
46
+ {visiblePolicyJobs.map((policyJob) => <AppActionTerminal job={policyJob} key={policyJob.id} onDismiss={onDismissJob} />)}
47
+ {visibleJob === null && visiblePolicyJobs.length === 0 ? <p className="empty-state compact-notice">No recent command output for this app. Run an action or change a policy toggle to open this app's terminal log.</p> : null}
48
+ </div> : null}
49
+ </article>
50
+ );
51
+ }
52
+
53
+ function isEssentialManagedApp(app: GuiManagedAppSummary): boolean {
54
+ return ["core", "safety", "automation"].includes(app.category);
55
+ }
56
+
57
+ function SummaryChip({ label, value }: { label: string; value: string }): ReactElement {
58
+ const tooltip = `${label}: ${value}`;
59
+ return <span className="managed-summary-chip" data-tooltip={tooltip}><small>{label}</small><span>{value}</span></span>;
60
+ }
61
+
62
+ function OriginLink({ href, label }: { href: string; label: string }): ReactElement {
63
+ if (href.length === 0) {
64
+ return <span className="origin-missing">{label}: source pending</span>;
65
+ }
66
+
67
+ return <a aria-label={`${label}: ${href}`} data-tooltip={href} href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank">{label} <FiExternalLink aria-hidden="true" /></a>;
68
+ }
69
+
70
+ function ToggleSwitch({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }): ReactElement {
71
+ return <button aria-pressed={checked} className={checked ? "managed-toggle checked" : "managed-toggle"} data-tooltip={disabled ? "Essential aidevops component; policy is locked on" : undefined} disabled={disabled} onClick={() => onChange(!checked)} type="button"><span className="managed-toggle-label">{label}</span><span aria-hidden="true" className={checked ? "switch-track checked" : "switch-track"}><span /></span></button>;
72
+ }
73
+
74
+ function AppMeta({ className = "", label, value }: { className?: string; label: string; value: string }): ReactElement {
75
+ return <span className={className.length > 0 ? `app-meta ${className}` : "app-meta"}><small>{label}</small><strong>{value}</strong></span>;
76
+ }
77
+
78
+ function AppLogLink({ job }: { job: GuiAppActionJobSummary }): ReactElement {
79
+ return <a className="app-meta app-log-link" href={`#app-job-${job.id}`}><small>Logs</small><strong>{job.action} · {job.status}</strong></a>;
80
+ }
81
+
82
+ function AppActionButton({ action, app, commandPreview, disabled, onJob }: { action: GuiAppActionId; app: GuiManagedAppSummary; commandPreview: string; disabled: boolean; onJob: (job: GuiAppActionJobSummary) => void }): ReactElement {
83
+ const icon = action === "install" ? <FiDownload /> : action === "update" ? <FiRefreshCw /> : action === "reinstall" ? <FiRepeat /> : <FiTrash2 />;
84
+ const [confirmOpen, setConfirmOpen] = useState(false);
85
+
86
+ async function runAction(): Promise<void> {
87
+ if (disabled) {
88
+ return;
89
+ }
90
+
91
+ try {
92
+ const response = await fetch(`/api/apps/${encodeURIComponent(app.id)}/actions/${action}`, { method: "POST" });
93
+ if (!response.ok) {
94
+ console.error(`Failed to run ${action} for ${app.id}: ${response.status} ${response.statusText}`);
95
+ return;
96
+ }
97
+
98
+ const envelope = await response.json() as Partial<GuiResponseEnvelope<GuiAppActionJobSummary>> | null;
99
+ if (envelope === null || typeof envelope !== "object") {
100
+ console.error(`Action ${action} for ${app.id} returned an invalid response envelope`);
101
+ return;
102
+ }
103
+
104
+ if (envelope.ok !== true) {
105
+ const errors = Array.isArray(envelope.errors) && envelope.errors.length > 0 ? envelope.errors.join("; ") : "unknown error";
106
+ console.error(`Action ${action} for ${app.id} returned an error envelope: ${errors}`);
107
+ return;
108
+ }
109
+
110
+ if (!("data" in envelope)) {
111
+ console.error(`Action ${action} for ${app.id} returned a response envelope without job data`);
112
+ return;
113
+ }
114
+
115
+ onJob(envelope.data as GuiAppActionJobSummary);
116
+ } catch (error) {
117
+ console.error(`Network error running ${action} for ${app.id}:`, error);
118
+ }
119
+ }
120
+
121
+ return <>
122
+ <button aria-label={`${action} ${app.name}`} className={action === "remove" ? "app-action-button remove" : "app-action-button"} data-tooltip={commandPreview} disabled={disabled} onClick={() => setConfirmOpen(true)} type="button">{icon}<span>{action}</span></button>
123
+ {confirmOpen ? <ConfirmActionModal action={action} app={app} commandPreview={commandPreview} close={() => setConfirmOpen(false)} confirm={() => { setConfirmOpen(false); void runAction(); }} /> : null}
124
+ </>;
125
+ }
126
+
127
+ function ConfirmActionModal({ action, app, close, commandPreview, confirm }: { action: GuiAppActionId; app: GuiManagedAppSummary; close: () => void; commandPreview: string; confirm: () => void }): ReactElement {
128
+ return (
129
+ <div className="modal-backdrop" role="presentation">
130
+ <section aria-modal="true" className="confirm-modal" role="dialog" aria-labelledby={`confirm-${app.id}-${action}`}>
131
+ <p className="eyebrow">Confirm action</p>
132
+ <h3 id={`confirm-${app.id}-${action}`}>{action} {app.name}?</h3>
133
+ <p>This will start the allowlisted background command for this app.</p>
134
+ <code>{commandPreview}</code>
135
+ <div className="confirm-modal-actions">
136
+ <button className="secondary-action" onClick={close} type="button">Cancel</button>
137
+ <button className={action === "remove" ? "app-action-button remove" : "app-action-button"} onClick={confirm} type="button">Confirm</button>
138
+ </div>
139
+ </section>
140
+ </div>
141
+ );
142
+ }
@@ -0,0 +1,199 @@
1
+ import type { ReactElement } from "react";
2
+ import type { IconType } from "react-icons";
3
+ import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
4
+ import { FiCode, FiExternalLink, FiGlobe, FiMonitor, FiTerminal } from "react-icons/fi";
5
+ import { IoLogoAndroid } from "react-icons/io";
6
+ import { SiIos } from "react-icons/si";
7
+ import { text } from "./app-model";
8
+ import { openExternalLink } from "./external-links";
9
+
10
+ export type RecommendedOsId = "all" | "macos" | "linux" | "windows" | "ios" | "android";
11
+ export type RecommendedPlatformId = "webapp" | "saas" | "cli" | "api";
12
+ export type RecommendedPlatformFilterId = "all" | RecommendedPlatformId;
13
+
14
+ interface TabOption<T extends string> {
15
+ id: T;
16
+ label: string;
17
+ }
18
+
19
+ interface RecommendedApp {
20
+ name: string;
21
+ description: string;
22
+ websiteUrl: string;
23
+ alternativeToUrl?: string;
24
+ repoUrl?: string;
25
+ iosUrl?: string;
26
+ androidUrl?: string;
27
+ fdroidUrl?: string;
28
+ obtainiumUrl?: string;
29
+ os: RecommendedOsId[];
30
+ platforms: RecommendedPlatformId[];
31
+ }
32
+
33
+ const recommendedPlatformTabs: TabOption<RecommendedPlatformFilterId>[] = [
34
+ { id: "all", label: "All" },
35
+ { id: "webapp", label: "WebApp" },
36
+ { id: "saas", label: "SaaS" },
37
+ { id: "cli", label: "CLI" },
38
+ { id: "api", label: "API" },
39
+ ];
40
+
41
+ const recommendedOsTabs: TabOption<RecommendedOsId>[] = [
42
+ { id: "all", label: "All OS" },
43
+ { id: "macos", label: "macOS" },
44
+ { id: "linux", label: "Linux" },
45
+ { id: "windows", label: "Windows" },
46
+ { id: "ios", label: "iOS" },
47
+ { id: "android", label: "Android" },
48
+ ];
49
+
50
+ export const recommendedApps = ([
51
+ { name: "Affinity Studio", description: "Creative design suite.", websiteUrl: "https://www.affinity.studio/", alternativeToUrl: "https://alternativeto.net/software/affinity-1/", os: ["macos", "windows"], platforms: [] },
52
+ { name: "Bitwarden", description: "Open source password manager.", websiteUrl: "https://bitwarden.com", alternativeToUrl: "https://alternativeto.net/software/bitwarden--free-password-manager/", repoUrl: "https://github.com/bitwarden", iosUrl: "https://itunes.apple.com/app/bitwarden-free-password-manager/id1137397744?mt=8", androidUrl: "https://play.google.com/store/apps/details?id=com.x8bit.bitwarden", fdroidUrl: "https://mobileapp.bitwarden.com/fdroid/repo", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "cli", "api"] },
53
+ { name: "Brave Browser", description: "Privacy-focused web browser.", websiteUrl: "https://brave.com/", alternativeToUrl: "https://alternativeto.net/software/brave/", iosUrl: "https://apps.apple.com/app/brave-private-web-browser-vpn/id1052879175?mt=8", androidUrl: "https://play.google.com/store/apps/details?id=com.brave.browser", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
54
+ { name: "Cloudron", description: "Self-hosted app platform.", websiteUrl: "https://www.cloudron.io/", alternativeToUrl: "https://alternativeto.net/software/cloudron/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
55
+ { name: "Collabora Online", description: "Online office collaboration.", websiteUrl: "https://www.collaboraonline.com/collabora-online/", alternativeToUrl: "https://alternativeto.net/software/collabora-online/", os: ["linux"], platforms: ["webapp", "api"] },
56
+ { name: "Cometly", description: "Marketing attribution analytics.", websiteUrl: "https://www.cometly.com/", os: [], platforms: ["saas", "api"] },
57
+ { name: "DaVinci Resolve", description: "Professional video editing, color, VFX, and audio post-production.", websiteUrl: "https://www.blackmagicdesign.com/products/davinciresolve/", alternativeToUrl: "https://alternativeto.net/software/davinci-resolve/", os: ["macos", "linux", "windows"], platforms: [] },
58
+ { name: "DocuSeal", description: "Open source document signing.", websiteUrl: "https://www.docuseal.com/", alternativeToUrl: "https://alternativeto.net/software/docuseal/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
59
+ { name: "Element", description: "Matrix messaging client.", websiteUrl: "https://element.io/", alternativeToUrl: "https://alternativeto.net/software/element-app/", iosUrl: "https://apps.apple.com/app/id1631335820", androidUrl: "https://play.google.com/store/apps/details?id=io.element.android.x", fdroidUrl: "https://f-droid.org/en/packages/io.element.android.x/", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp"] },
60
+ { name: "Enpass", description: "Offline-first password manager.", websiteUrl: "https://www.enpass.io/", alternativeToUrl: "https://alternativeto.net/software/enpass/", iosUrl: "https://apps.apple.com/app/apple-store/id455566716?pt=637991", androidUrl: "https://play.google.com/store/apps/details?id=io.enpass.app", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
61
+ { name: "EspoCRM", description: "Open source CRM.", websiteUrl: "https://www.espocrm.com/", alternativeToUrl: "https://alternativeto.net/software/espocrm/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
62
+ { name: "Fathom Analytics", description: "Privacy-first analytics.", websiteUrl: "https://usefathom.com/", alternativeToUrl: "https://alternativeto.net/software/fathom-analytics/", os: [], platforms: ["saas", "api"] },
63
+ { name: "FontBase", description: "Font management and reference tool.", websiteUrl: "https://fontba.se/", alternativeToUrl: "https://alternativeto.net/software/fontbase/", os: ["macos"], platforms: [] },
64
+ { name: "Forgejo", description: "Self-hosted Git forge.", websiteUrl: "https://forgejo.org/", alternativeToUrl: "https://alternativeto.net/software/forgejo/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
65
+ { name: "Ghost", description: "Publishing platform.", websiteUrl: "https://ghost.org/", alternativeToUrl: "https://alternativeto.net/software/ghost/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
66
+ { name: "Gitea", description: "Self-hosted Git service.", websiteUrl: "https://about.gitea.com/", alternativeToUrl: "https://alternativeto.net/software/gitea/", os: ["linux", "windows"], platforms: ["webapp", "cli", "api"] },
67
+ { name: "GitLab", description: "DevSecOps and Git hosting platform.", websiteUrl: "https://gitlab.com/", alternativeToUrl: "https://alternativeto.net/software/gitlab/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
68
+ { name: "LibreOffice", description: "Open source office suite.", websiteUrl: "https://www.libreoffice.org/", alternativeToUrl: "https://alternativeto.net/software/libreoffice/", os: ["macos", "linux", "windows"], platforms: [] },
69
+ { name: "LocalWP", description: "Local WordPress development.", websiteUrl: "https://localwp.com/", alternativeToUrl: "https://alternativeto.net/software/local-by-flywheel/", os: ["macos", "linux", "windows"], platforms: [] },
70
+ { name: "Matomo", description: "Open analytics platform.", websiteUrl: "https://matomo.org/", alternativeToUrl: "https://alternativeto.net/software/piwik/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
71
+ { name: "Nextcloud", description: "Open source content collaboration platform.", websiteUrl: "https://nextcloud.com/", alternativeToUrl: "https://alternativeto.net/software/nextcloud/", repoUrl: "https://github.com/nextcloud", iosUrl: "https://itunes.apple.com/us/app/nextcloud/id1125420102?mt=8", androidUrl: "https://play.google.com/store/apps/details?id=com.nextcloud.client", fdroidUrl: "https://f-droid.org/packages/com.nextcloud.client/", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "api"] },
72
+ { name: "Nextcloud Talk", description: "Open source video calls, chat, and collaboration for Nextcloud.", websiteUrl: "https://nextcloud.com/talk/", repoUrl: "https://github.com/nextcloud/spreed", iosUrl: "https://apps.apple.com/us/app/nextcloud-talk/id1296825574", androidUrl: "https://play.google.com/store/apps/details?id=com.nextcloud.talk2&hl=en", os: ["ios", "android"], platforms: ["webapp"] },
73
+ { name: "OBS Studio", description: "Open source video recording and live streaming.", websiteUrl: "https://obsproject.com/", alternativeToUrl: "https://alternativeto.net/software/open-broadcaster-software/", repoUrl: "https://github.com/obsproject/obs-studio", os: ["macos", "linux", "windows"], platforms: [] },
74
+ { name: "ONLYOFFICE", description: "Office and document collaboration.", websiteUrl: "https://www.onlyoffice.com/", alternativeToUrl: "https://alternativeto.net/software/onlyoffice/", repoUrl: "https://github.com/ONLYOFFICE/", iosUrl: "https://apps.apple.com/us/app/onlyoffice-documents/id944896972", androidUrl: "https://play.google.com/store/apps/details?id=com.onlyoffice.documents", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "api"] },
75
+ { name: "OpenScreen", description: "Open screen-sharing project.", websiteUrl: "https://github.com/getopenscreen/openscreen/releases", alternativeToUrl: "https://alternativeto.net/software/openscreen/", repoUrl: "https://github.com/getopenscreen/openscreen", os: ["macos", "linux", "windows"], platforms: [] },
76
+ { name: "Osaurus", description: "AI app workspace.", websiteUrl: "https://osaurus.ai/", alternativeToUrl: "https://alternativeto.net/software/osaurus/", os: ["macos"], platforms: [] },
77
+ { name: "Parallels Desktop", description: "Run Windows, Linux, and virtual machines on macOS.", websiteUrl: "https://www.parallels.com/products/desktop/", alternativeToUrl: "https://alternativeto.net/software/parallels-desktop/about/", os: ["macos"], platforms: [] },
78
+ { name: "PDF Studio", description: "PDF editor.", websiteUrl: "https://www.qoppa.com/pdfstudio/", alternativeToUrl: "https://alternativeto.net/software/qoppa-pdf-studio/", os: ["macos", "linux", "windows"], platforms: [] },
79
+ { name: "Pixelmator Pro", description: "Professional image editor for macOS.", websiteUrl: "https://www.apple.com/pixelmator-pro/", os: ["macos"], platforms: [] },
80
+ { name: "PostHog", description: "Product analytics platform.", websiteUrl: "https://posthog.com/", alternativeToUrl: "https://alternativeto.net/software/posthog/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
81
+ { name: "Postiz", description: "Social media scheduling.", websiteUrl: "https://postiz.com/", alternativeToUrl: "https://alternativeto.net/software/postiz/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
82
+ { name: "PrivateBin", description: "Zero-knowledge pastebin.", websiteUrl: "https://privatebin.info/", alternativeToUrl: "https://alternativeto.net/software/privatebin/", repoUrl: "https://github.com/PrivateBin/PrivateBin", os: ["linux"], platforms: ["webapp", "api"] },
83
+ { name: "Proxmox", description: "Virtualization platform.", websiteUrl: "https://www.proxmox.com/", alternativeToUrl: "https://alternativeto.net/software/proxmox-virtual-environment/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
84
+ { name: "QuickFile", description: "Accounting platform.", websiteUrl: "https://www.quickfile.co.uk/", os: [], platforms: ["saas", "api"] },
85
+ { name: "Reframed", description: "Creative framing utility.", websiteUrl: "https://www.reframed.dev/", alternativeToUrl: "https://alternativeto.net/software/reframed/", os: ["macos"], platforms: [] },
86
+ { name: "Rybbit", description: "Web analytics.", websiteUrl: "https://rybbit.com/", alternativeToUrl: "https://alternativeto.net/software/rybbit/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
87
+ { name: "SchildiChat", description: "Matrix messaging client.", websiteUrl: "https://schildi.chat/", alternativeToUrl: "https://alternativeto.net/software/schildichat/", androidUrl: "https://play.google.com/store/apps/details?id=chat.schildi.android", fdroidUrl: "https://schildi.chat/next/install-from-sc-fdroid", os: ["macos", "linux", "windows", "android"], platforms: ["webapp"] },
88
+ { name: "ScreenFlow", description: "Screen recording and editing.", websiteUrl: "https://www.telestream.net/screenflow/", alternativeToUrl: "https://alternativeto.net/software/screenflow/", os: ["macos"], platforms: [] },
89
+ { name: "SEO Utils", description: "Desktop SEO tools.", websiteUrl: "https://seoutils.app/", os: ["macos", "linux", "windows"], platforms: [] },
90
+ { name: "Shottr", description: "macOS screenshot utility.", websiteUrl: "https://shottr.cc/", alternativeToUrl: "https://alternativeto.net/software/shottr/", os: ["macos"], platforms: [] },
91
+ { name: "Signal", description: "Private messenger.", websiteUrl: "https://signal.org/", alternativeToUrl: "https://alternativeto.net/software/signal-private-messenger/", iosUrl: "https://apps.apple.com/us/app/signal-private-messenger/id874139669", androidUrl: "https://play.google.com/store/apps/details?id=org.thoughtcrime.securesms", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
92
+ { name: "SimpleX Chat", description: "Private messenger with no user IDs.", websiteUrl: "https://simplex.chat/", alternativeToUrl: "https://alternativeto.net/software/simplex-chat/about/", repoUrl: "https://github.com/simplex-chat", iosUrl: "https://apps.apple.com/us/app/simplex-chat/id1605771084", androidUrl: "https://play.google.com/store/apps/details?id=chat.simplex.app", fdroidUrl: "https://simplex.chat/fdroid", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["cli"] },
93
+ { name: "Telegram", description: "Cloud-based mobile and desktop messaging app.", websiteUrl: "https://telegram.org/", iosUrl: "https://apps.apple.com/us/app/telegram-messenger/id686449807", androidUrl: "https://play.google.com/store/apps/details?id=org.telegram.messenger", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "api"] },
94
+ { name: "Thunderbird", description: "Email and calendar app.", websiteUrl: "https://www.thunderbird.net/", alternativeToUrl: "https://alternativeto.net/software/mozilla-thunderbird/about/", os: ["macos", "linux", "windows"], platforms: [] },
95
+ { name: "Ubicloud", description: "Open cloud platform.", websiteUrl: "https://www.ubicloud.com/", alternativeToUrl: "https://alternativeto.net/software/ubicloud/about/", repoUrl: "https://github.com/ubicloud/ubicloud", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
96
+ { name: "Vaultwarden", description: "Alternative Bitwarden server implementation.", websiteUrl: "https://github.com/dani-garcia/vaultwarden", alternativeToUrl: "https://alternativeto.net/software/vaultwarden/", repoUrl: "https://github.com/dani-garcia/vaultwarden", os: ["linux"], platforms: ["webapp", "api"] },
97
+ { name: "VideoProc", description: "Video processing toolkit.", websiteUrl: "https://www.videoproc.com/", alternativeToUrl: "https://alternativeto.net/software/videoproc/about/", os: ["macos", "windows"], platforms: [] },
98
+ { name: "VirtualBox", description: "Virtualization app.", websiteUrl: "https://www.virtualbox.org/", alternativeToUrl: "https://alternativeto.net/software/virtualbox/about/", os: ["macos", "linux", "windows"], platforms: ["cli"] },
99
+ { name: "WordPress", description: "Open source publishing platform.", websiteUrl: "https://wordpress.org/", alternativeToUrl: "https://alternativeto.net/software/wordpress/about/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
100
+ ] satisfies RecommendedApp[]).sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
101
+
102
+ function TabNav<T extends string>({ label, onChange, tabs, value }: { label: string; onChange: (value: T) => void; tabs: TabOption<T>[]; value: T }): ReactElement {
103
+ return <div aria-label={label} className="pill-tabs app-subnav" role="tablist">{tabs.map((tab) => <button aria-selected={value === tab.id} className={value === tab.id ? "active" : ""} key={tab.id} onClick={() => onChange(tab.id)} role="tab" type="button">{tab.label}</button>)}</div>;
104
+ }
105
+
106
+ export function RecommendedAppsSurface({ apps, os, platform, setOs, setPlatform }: { apps: RecommendedApp[]; os: RecommendedOsId; platform: RecommendedPlatformFilterId; setOs: (os: RecommendedOsId) => void; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
107
+ return <>
108
+ <div className="recommended-filter-tabs">
109
+ <TabNav label="Recommended app operating system filters" tabs={recommendedOsTabs} value={os} onChange={setOs} />
110
+ <TabNav label="Recommended app platform filters" tabs={recommendedPlatformTabs} value={platform} onChange={setPlatform} />
111
+ </div>
112
+ <div className="recommended-app-grid">
113
+ {apps.map((app) => <RecommendedAppCard app={app} key={app.name} setOs={setOs} setPlatform={setPlatform} />)}
114
+ </div>
115
+ </>;
116
+ }
117
+
118
+ function RecommendedAppCard({ app, setOs, setPlatform }: { app: RecommendedApp; setOs: (os: RecommendedOsId) => void; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
119
+ return <article className="recommended-app-card">
120
+ <div>
121
+ <a aria-label={`${app.name}: ${app.websiteUrl}`} className="recommended-app-title-link" data-tooltip={app.websiteUrl} href={app.websiteUrl} onClick={(event) => openExternalLink(event, app.websiteUrl)} rel="noreferrer" target="_blank"><strong>{app.name}</strong></a>
122
+ <p>{app.description}</p>
123
+ </div>
124
+ <div className="app-icon-groups">
125
+ <PlatformIconList platforms={app.platforms} setPlatform={setPlatform} />
126
+ <OsIconList os={app.os} setOs={setOs} />
127
+ </div>
128
+ <div className="managed-app-links plain-links">
129
+ <OriginLink href={app.websiteUrl} label={text.website} />
130
+ {app.alternativeToUrl ? <OriginLink href={app.alternativeToUrl} label="AlternativeTo" /> : null}
131
+ {app.repoUrl ? <OriginLink href={app.repoUrl} label="Repo" /> : null}
132
+ {app.iosUrl ? <OriginLink href={app.iosUrl} label="iOS" /> : null}
133
+ {app.androidUrl ? <OriginLink href={app.androidUrl} label="Android" /> : null}
134
+ {app.fdroidUrl ? <OriginLink href={app.fdroidUrl} label="F-Droid" /> : null}
135
+ {app.obtainiumUrl ? <OriginLink href={app.obtainiumUrl} label="Obtainium" /> : null}
136
+ </div>
137
+ </article>;
138
+ }
139
+
140
+ function OriginLink({ href, label }: { href: string; label: string }): ReactElement {
141
+ if (href.length === 0) {
142
+ return <span className="origin-missing">{label}: source pending</span>;
143
+ }
144
+
145
+ return <a aria-label={`${label}: ${href}`} data-tooltip={href} href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank">{label} <FiExternalLink aria-hidden="true" /></a>;
146
+ }
147
+
148
+ function PlatformIconList({ platforms, setPlatform }: { platforms: RecommendedPlatformId[]; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement | null {
149
+ if (platforms.length === 0) {
150
+ return null;
151
+ }
152
+
153
+ return <span className="os-icon-list platform-icon-list">{platforms.map((platform) => <PlatformIcon id={platform} key={platform} setPlatform={setPlatform} />)}</span>;
154
+ }
155
+
156
+ function PlatformIcon({ id, setPlatform }: { id: RecommendedPlatformId; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
157
+ const iconMap: Record<RecommendedPlatformId, { Icon: IconType; label: string }> = {
158
+ webapp: { Icon: FiMonitor, label: "WebApp" },
159
+ saas: { Icon: FiGlobe, label: "SaaS" },
160
+ cli: { Icon: FiTerminal, label: "CLI" },
161
+ api: { Icon: FiCode, label: "API" },
162
+ };
163
+ const { Icon, label } = iconMap[id];
164
+
165
+ return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setPlatform(id)} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
166
+ }
167
+
168
+ function OsIconList({ os, setOs }: { os: RecommendedOsId[]; setOs: (os: RecommendedOsId) => void }): ReactElement | null {
169
+ if (os.length === 0) {
170
+ return null;
171
+ }
172
+
173
+ return <span className="os-icon-list">{os.map((item) => <OsIcon id={item} key={item} setOs={setOs} />)}</span>;
174
+ }
175
+
176
+ function OsIcon({ id, setOs }: { id: RecommendedOsId; setOs: (os: RecommendedOsId) => void }): ReactElement {
177
+ const iconMap: Record<RecommendedOsId, { Icon: IconType; label: string }> = {
178
+ all: { Icon: FiGlobe, label: "Web app" },
179
+ macos: { Icon: FaApple, label: "macOS" },
180
+ linux: { Icon: FaLinux, label: "Linux" },
181
+ windows: { Icon: FaWindows, label: "Windows" },
182
+ ios: { Icon: SiIos, label: "iOS" },
183
+ android: { Icon: IoLogoAndroid, label: "Android" },
184
+ };
185
+ const { Icon, label } = iconMap[id];
186
+
187
+ return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setOs(id)} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
188
+ }
189
+
190
+ export function recommendedAppMatchesFilters(app: RecommendedApp, platform: RecommendedPlatformFilterId, os: RecommendedOsId): boolean {
191
+ const platformMatches = platform === "all" || app.platforms.includes(platform);
192
+ const osMatches = os === "all" || app.os.includes(os);
193
+
194
+ return platformMatches && osMatches;
195
+ }
196
+
197
+ export function nextRecommendedFilterValue<T extends string>(current: T, next: T, defaultValue: T): T {
198
+ return current === next && next !== defaultValue ? defaultValue : next;
199
+ }
@@ -1,6 +1,7 @@
1
1
  import type { GuiFileRootId, GuiStatusData } from "@aidevops/gui-shared";
2
2
 
3
3
  export type ThemePreference = "system" | "light" | "dark";
4
+ export type ContrastPreference = "low" | "medium" | "high";
4
5
  export type SidebarMode = "devops" | "comms";
5
6
  export type ShellMode = "devices" | "sessions";
6
7
  export type ConversationMode = "ai" | "people";
@@ -129,6 +130,11 @@ export interface FontSizeOption {
129
130
  cssSize: string;
130
131
  }
131
132
 
133
+ export interface ContrastOption {
134
+ label: string;
135
+ value: ContrastPreference;
136
+ }
137
+
132
138
  export interface InventoryColumn {
133
139
  key: string;
134
140
  label: string;
@@ -153,6 +159,7 @@ export const text = {
153
159
  brandsIntro: "Draft brand inventory for names and website references.",
154
160
  codeView: "Code",
155
161
  config: "Config",
162
+ contrast: "Contrast",
156
163
  configIntro: "Read-only file explorer for ~/.config/aidevops. Contents stay hidden until redaction rules land.",
157
164
  copyPath: "Copy path",
158
165
  dashboard: "Dashboard",
@@ -303,9 +310,16 @@ export const text = {
303
310
  } as const;
304
311
 
305
312
  export const DEFAULT_ACCENT_HUE = 123;
313
+ export const DEFAULT_CONTRAST: ContrastPreference = "low";
306
314
  export const DEFAULT_FONT: FontPreference = "Inter";
307
315
  export const DEFAULT_FONT_SIZE: FontSizePreference = "xs";
308
316
 
317
+ export const contrastOptions: ContrastOption[] = [
318
+ { value: "low", label: "Low" },
319
+ { value: "medium", label: "Medium" },
320
+ { value: "high", label: "High" },
321
+ ];
322
+
309
323
  export const fontOptions: FontOption[] = [
310
324
  { value: "IBM Plex Mono", label: "IBM Plex Mono", fontFamily: '"IBM Plex Mono", Menlo, Monaco, Consolas, monospace' },
311
325
  { value: "IBM Plex Sans", label: "IBM Plex Sans", fontFamily: '"IBM Plex Sans", Inter, system-ui, sans-serif' },
@@ -558,6 +572,10 @@ export function isFontSizePreference(value: string | null): value is FontSizePre
558
572
  return fontSizeOptions.some((option) => option.value === value);
559
573
  }
560
574
 
575
+ export function isContrastPreference(value: string | null): value is ContrastPreference {
576
+ return contrastOptions.some((option) => option.value === value);
577
+ }
578
+
561
579
  export function sidebarModeForSurface(id: SurfaceId): SidebarMode {
562
580
  if (utilityNavItems.some((item) => item.id === id)) {
563
581
  return "comms";
@@ -55,7 +55,7 @@ export function renderDashboardHtml(status: GuiResponseEnvelope<GuiStatusData>):
55
55
  `<h1>aidevops app interface</h1>`,
56
56
  `<p>Made for creators.</p>`,
57
57
  `<p>AI-assisted development workflows, code quality, and deployment automation.</p>`,
58
- `<p>Theme follows system preferences with light and dark overrides. Sidebar modes: DevOps and Comms. Appearance controls can be hidden or shown, and include editable Hue, icon Reset, Show borders toggle, Show counts toggle, Font size choices xs, s, m, lg, xl, and Font options: IBM Plex Mono, IBM Plex Sans, IBM Plex Serif, Inter (default), Menlo, Playpen Sans, Poppins, Source Sans, Source Serif, Tilt Neon, Ubuntu Mono. The workspace header includes a command palette, reserved signposts/help slot immediately left of notifications, notifications menu, AI Assistant panel, and profile menu with Settings, Theme, Language, Community, Admin, and Logout entries. Notifications reuse the OpenCode toast status cache and GUI readiness data. A desktop status bar shows local readiness, repo totals, secret reference count, provider accounts, and update state.</p>`,
58
+ `<p>Theme follows system preferences with light and dark overrides. Sidebar modes: DevOps and Comms. Appearance controls can be hidden or shown, and include editable Hue, Contrast levels low, medium, high, icon Reset, Show borders toggle, Show counts toggle, Font size choices xs, s, m, lg, xl, and Font options: IBM Plex Mono, IBM Plex Sans, IBM Plex Serif, Inter (default), Menlo, Playpen Sans, Poppins, Source Sans, Source Serif, Tilt Neon, Ubuntu Mono. The workspace header includes a command palette, reserved signposts/help slot immediately left of notifications, notifications menu, AI Assistant panel, and profile menu with Settings, Theme, Language, Community, Admin, and Logout entries. Notifications reuse the OpenCode toast status cache and GUI readiness data. A desktop status bar shows local readiness, repo totals, secret reference count, provider accounts, and update state.</p>`,
59
59
  `<p>Secret references stay read-only and redacted.</p>`,
60
60
  `<p>Vault surfaces show padlock indicators. Encrypted by aidevops Vault; contents visible only when unlocked through app or authorised vault commands.</p>`,
61
61
  `<p>${escapeHtml(status.data.update.message)}</p>`,
@@ -0,0 +1,60 @@
1
+ import type { MouseEvent as ReactMouseEvent } from "react";
2
+
3
+ interface WebKitExternalLinkWindow extends Window {
4
+ webkit?: {
5
+ messageHandlers?: {
6
+ externalLink?: {
7
+ postMessage: (href: string) => void;
8
+ };
9
+ };
10
+ };
11
+ }
12
+
13
+ export function openExternalLink(event: ReactMouseEvent<HTMLAnchorElement>, href: string): void {
14
+ event.stopPropagation();
15
+ if (shouldUseBrowserDefault(event)) {
16
+ return;
17
+ }
18
+
19
+ event.preventDefault();
20
+ openExternalDestination(href);
21
+ }
22
+
23
+ function shouldUseBrowserDefault(event: ReactMouseEvent<HTMLAnchorElement>): boolean {
24
+ return [event.defaultPrevented, event.metaKey, event.ctrlKey, event.shiftKey, event.altKey].some(Boolean);
25
+ }
26
+
27
+ function openExternalDestination(href: string): void {
28
+ if (document.documentElement.dataset.desktopShell === "macos") {
29
+ if (postNativeExternalLink(href)) {
30
+ return;
31
+ }
32
+
33
+ const opened = window.open(href, "_blank", "noopener,noreferrer");
34
+ if (opened !== null) {
35
+ opened.opener = null;
36
+ }
37
+ return;
38
+ }
39
+
40
+ const opened = window.open(href, "_blank", "noopener,noreferrer");
41
+ if (opened === null) {
42
+ window.location.assign(href);
43
+ } else {
44
+ opened.opener = null;
45
+ }
46
+ }
47
+
48
+ function postNativeExternalLink(href: string): boolean {
49
+ const handler = (window as WebKitExternalLinkWindow).webkit?.messageHandlers?.externalLink;
50
+ if (handler === undefined) {
51
+ return false;
52
+ }
53
+
54
+ try {
55
+ handler.postMessage(href);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }