diff --git a/.claude/skills/swiftui-design-skill/.gitignore b/.claude/skills/swiftui-design-skill/.gitignore new file mode 100644 index 0000000..31f0a5f --- /dev/null +++ b/.claude/skills/swiftui-design-skill/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +*.swp +*.swo +*~ +node_modules/ diff --git a/.claude/skills/swiftui-design-skill/LICENSE b/.claude/skills/swiftui-design-skill/LICENSE new file mode 100644 index 0000000..573247b --- /dev/null +++ b/.claude/skills/swiftui-design-skill/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 wholiver + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.claude/skills/swiftui-design-skill/README-zh.md b/.claude/skills/swiftui-design-skill/README-zh.md new file mode 100644 index 0000000..dcb3972 --- /dev/null +++ b/.claude/skills/swiftui-design-skill/README-zh.md @@ -0,0 +1,128 @@ +
+ +# SwiftUI Design Skill + +> *「一句话,一个能交付的 SwiftUI 界面。」* +> *"One prompt. A shippable SwiftUI interface."* + +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Agent-Agnostic](https://img.shields.io/badge/Agent-Agnostic-blueviolet)](https://skills.sh) +[![Skills](https://img.shields.io/badge/skills.sh-Compatible-green)](https://skills.sh) + +
+ +

+ English + 中文 +

+ +**在你的 agent 里打一句话,拿回一个不 AI slop 的 SwiftUI 界面。** + +3 到 30 分钟,你能设计出一个**有签名感的 iOS 界面**——不是「AI 做的还行」那种水平,是看起来像有品味的设计师做的。 + +反 AI slop 六条铁律、设计方向顾问、品牌资产协议、五维评审——全部内置。给 skill 你的品牌色板,它会读懂你的气质;什么都不给,内置的 5 种设计语汇也能兜底到不出丑。 + +跨 agent 通用——Claude Code、Cursor、Codex、OpenCode、Hermes 都能装。 + +## 装上就能用 + +```bash +npx skills add wholiver/swiftui-design-skill -g -y +``` + +## 能做什么 + +| 能力 | 交付物 | 典型耗时 | +|------|--------|----------| +| SwiftUI 界面设计 | 可编译的 SwiftUI 代码 · 设计系统 token · 布局模式 | 10–15 min | +| 设计方向顾问 | 5 流派 × N 种设计哲学 · 推荐 3 方向 · 视觉锚点描述 | 5 min | +| 品牌资产集成 | brand-spec.md · 色板 · 字体 · 间距系统 | 5–10 min | +| 反 AI Slop 审查 | 逐条检查 · 具体修复代码 · 替代方案 | 3–5 min | +| 五维评审 | 雷达图评分 · Keep/Fix/Quick Wins · 可操作修复清单 | 3 min | +| 布局模式库 | 9 种常见布局 · 可复制的 SwiftUI 代码 | 即时 | +| 动画设计 | spring/parallax/pull-to-refresh · 可编译代码 | 5–10 min | + +## 核心机制 + +### 反 AI Slop 六条铁律 + +这是 skill 里最硬的一段规则。所有设计都必须通过这 6 条检查: + +| # | 铁律 | ❌ 禁止 | ✅ 替代方案 | +|---|------|---------|------------| +| 1 | 起点从上下文开始 | 空白画布凭空捏造 | 先问设计系统/UI kit/品牌资产 | +| 2 | 初级设计师模式 | 等完美方案 | 灰色占位符 > 烂 SVG | +| 3 | 给变体不给终稿 | 一个「最终答案」 | 2–3 个差异化方向 | +| 4 | 占位符 > 烂实现 | AI 生成的剪贴画 | 干净的灰色占位 + 文字标注 | +| 5 | 系统优先,别填充 | 塞满每一个像素 | 每个元素必须证明自己存在的理由 | +| 6 | 禁止 AI slop 模式 | 紫蓝渐变、emoji 图标、圆角卡片+左边框 | 单一暖色强调色、SF Symbols、衬线标题 | + +详见 `references/anti-ai-slop.md`。 + +### 设计方向顾问 + +当需求模糊时,skill 会从 5 大设计流派中推荐 3 个差异化方向: + +| 流派 | 特征 | 代表风格 | +|------|------|----------| +| **信息型** | 数据优先、图表密集 | Bloomberg Terminal | +| **编辑型** | 杂志排版、衬线字体、大量留白 | NYT、Medium | +| **表现型** | 大胆配色、不对称布局、动效前置 | Stripe、Linear | +| **功能型** | 密集工具感、等宽字体点缀 | Things、OmniFocus | +| **温暖极简** | 柔和中性色、圆角、微妙质感 | Notion、Craft | + +### 品牌资产协议 + +涉及具体品牌时强制执行 5 步硬流程: + +| 步骤 | 动作 | 目的 | +|------|------|------| +| 1 · 问 | 用户有 brand guidelines 吗? | 尊重已有资源 | +| 2 · 搜 | 搜索品牌官方页面 | 获取真实素材 | +| 3 · 下 | 下载实际资产文件 | PNG/SVG logo、字体 | +| 4 · 验 | 验证颜色匹配官方来源 | 核对 hex 值 | +| 5 · 写 | 生成 brand-spec.md | 记录完整设计系统 | + +质量门槛:5 个真实品牌色、10 个设计 token、2 个字体家族、8pt 间距网格。 + +### 五维评审 + +每个设计交付前必须通过 5 个维度的评审: + +| 维度 | 评分标准 | 最低分 | +|------|----------|--------| +| 🎯 哲学一致性 | 设计是否贯彻了选定的设计哲学 | ≥ 7/10 | +| 📐 视觉层级 | 信息优先级是否清晰 | ≥ 7/10 | +| 🔧 细节执行 | 间距、字体、颜色是否精确 | ≥ 7/10 | +| ⚡ 功能性 | 布局是否服务用户目标 | ≥ 7/10 | +| ✨ 原创性 | 是否有 1 个签名细节 | ≥ 7/10 | + +## 文件结构 + +``` +swiftui-design-skill/ +├── SKILL.md ← 核心定义(278 行) +├── references/ +│ ├── anti-ai-slop.md ← 反 AI Slop 详细规则(268 行) +│ ├── layout-patterns.md ← 9 种布局模式 + 可复制代码(265 行) +│ ├── typography-color.md ← 字体层级 + 色彩系统(172 行) +│ ├── design-review.md ← 五维评审流程(151 行) +│ └── swift-extensions.md ← Color/Font/Animation 扩展(373 行,必备) +└── templates/ + └── brand-spec.md ← 品牌规范模板(77 行) +``` + +## 与 swiftui-expert-skill 的区别 + +| | SwiftUI Design Skill | swiftui-expert-skill | +|---|---|---| +| **焦点** | 视觉设计、美学、品牌感 | 代码质量、性能、正确性 | +| **问题** | 「看起来怎么样?」 | 「代码写得对不对?」 | +| **输出** | 设计方向、色板、布局、评审 | 代码审查、Instruments 分析、API 现代化 | +| **互补** | 先用 design 确定方向 | 再用 expert 确保代码质量 | + +两个 skill 可以配合使用:Design 确定「做什么」,Expert 确保「怎么做对」。 + +## License + +MIT — 随便用,但请保留原作者署名。 diff --git a/.claude/skills/swiftui-design-skill/README.md b/.claude/skills/swiftui-design-skill/README.md new file mode 100644 index 0000000..6db14a4 --- /dev/null +++ b/.claude/skills/swiftui-design-skill/README.md @@ -0,0 +1,127 @@ +
+ +# SwiftUI Design Skill + +> *"One prompt. A shippable SwiftUI interface."* + +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Agent-Agnostic](https://img.shields.io/badge/Agent-Agnostic-blueviolet)](https://skills.sh) +[![Skills](https://img.shields.io/badge/skills.sh-Compatible-green)](https://skills.sh) + +
+ +

+ English + 中文 +

+ +**Type one line in your agent. Get back a SwiftUI interface that doesn't look AI-generated.** + +In 3 to 30 minutes, you can design a **signature iOS interface** — not "AI-made-it-looks-okay" quality, but something that looks like it was crafted by a designer with taste. + +Anti-AI Slop 6 Iron Rules, Design Direction Advisor, Brand Asset Protocol, 5-Dimension Review — all built in. Give the skill your brand palette and it will read your vibe; give nothing and the 5 built-in design languages will still hold their ground. + +Works across agents — Claude Code, Cursor, Codex, OpenCode, Hermes, all supported. + +## Install & Go + +```bash +npx skills add wholiver/swiftui-design-skill -g -y +``` + +## What Can It Do + +| Capability | Deliverable | Typical Time | +|------------|-------------|--------------| +| SwiftUI Interface Design | Compilable SwiftUI code · Design system tokens · Layout patterns | 10–15 min | +| Design Direction Advisor | 5 schools × N design philosophies · 3 recommended directions · Visual anchor descriptions | 5 min | +| Brand Asset Integration | brand-spec.md · Color palette · Typography · Spacing system | 5–10 min | +| Anti-AI Slop Review | Item-by-item check · Concrete fix code · Alternative approaches | 3–5 min | +| 5-Dimension Review | Radar chart scoring · Keep/Fix/Quick Wins · Actionable fix checklist | 3 min | +| Layout Pattern Library | 9 common layouts · Copy-paste SwiftUI code | Instant | +| Animation Design | Spring/parallax/pull-to-refresh · Compilable code | 5–10 min | + +## Core Mechanisms + +### Anti-AI Slop 6 Iron Rules + +This is the hardest set of rules in the skill. All designs must pass these 6 checks: + +| # | Iron Rule | ❌ Forbidden | ✅ Alternative | +|---|-----------|-------------|----------------| +| 1 | Start from context | Inventing from a blank canvas | Ask about design system / UI kit / brand assets first | +| 2 | Junior Designer Mode | Waiting for the perfect solution | Gray placeholder > bad SVG | +| 3 | Give variants, not finals | One "final answer" | 2–3 differentiated directions | +| 4 | Placeholder > bad implementation | AI-generated clip art | Clean gray placeholder + text label | +| 5 | System-first, don't fill | Packing every pixel | Every element must justify its existence | +| 6 | Ban AI slop patterns | Purple-blue gradients, emoji icons, rounded cards + left border | Single warm accent color, SF Symbols, serif headlines | + +See `references/anti-ai-slop.md` for details. + +### Design Direction Advisor + +When requirements are vague, the skill recommends 3 differentiated directions from 5 major design schools: + +| School | Characteristics | Signature Style | +|--------|-----------------|-----------------| +| **Informational** | Data-first, chart-dense | Bloomberg Terminal | +| **Editorial** | Magazine layout, serif type, generous whitespace | NYT, Medium | +| **Expressive** | Bold color, asymmetric layout, motion-forward | Stripe, Linear | +| **Functional** | Dense tool feel, monospace accents | Things, OmniFocus | +| **Warm Minimal** | Soft neutrals, rounded corners, subtle texture | Notion, Craft | + +### Brand Asset Protocol + +A mandatory 5-step hard process when working with a specific brand: + +| Step | Action | Purpose | +|------|--------|---------| +| 1 · Ask | Does the user have brand guidelines? | Respect existing assets | +| 2 · Search | Search the brand's official pages | Obtain real materials | +| 3 · Download | Download actual asset files | PNG/SVG logos, fonts | +| 4 · Verify | Verify colors match official sources | Cross-check hex values | +| 5 · Write | Generate brand-spec.md | Record complete design system | + +Quality threshold: 5 real brand colors, 10 design tokens, 2 font families, 8pt spacing grid. + +### 5-Dimension Review + +Every design must pass a 5-dimension review before delivery: + +| Dimension | Scoring Criteria | Minimum Score | +|-----------|------------------|---------------| +| 🎯 Philosophy Consistency | Does the design embody the chosen design philosophy? | ≥ 7/10 | +| 📐 Visual Hierarchy | Is the information priority clear? | ≥ 7/10 | +| 🔧 Detail Execution | Are spacing, typography, and colors precise? | ≥ 7/10 | +| ⚡ Functionality | Does the layout serve user goals? | ≥ 7/10 | +| ✨ Originality | Is there at least 1 signature detail? | ≥ 7/10 | + +## File Structure + +``` +swiftui-design-skill/ +├── SKILL.md ← Core definition (278 lines) +├── references/ +│ ├── anti-ai-slop.md ← Anti-AI Slop detailed rules (268 lines) +│ ├── layout-patterns.md ← 9 layout patterns + copy-paste code (265 lines) +│ ├── typography-color.md ← Typography hierarchy + color system (172 lines) +│ ├── design-review.md ← 5-Dimension Review process (151 lines) +│ └── swift-extensions.md ← Color/Font/Animation extensions (373 lines, essential) +└── templates/ + └── brand-spec.md ← Brand spec template (77 lines) +``` + +## Difference from swiftui-expert-skill + +| | SwiftUI Design Skill | swiftui-expert-skill | +|---|---|---| +| **Focus** | Visual design, aesthetics, brand feel | Code quality, performance, correctness | +| **Question** | "How does it look?" | "Is the code correct?" | +| **Output** | Design direction, color palette, layout, review | Code review, Instruments analysis, API modernization | +| **Complementary** | Use Design first to set direction | Then use Expert to ensure code quality | + +The two skills work together: Design decides **what to build**, Expert ensures **how to build it right**. + +## License + +MIT — use freely, but please keep the original author attribution. diff --git a/.claude/skills/swiftui-design-skill/RELEASE-v1.0.0.md b/.claude/skills/swiftui-design-skill/RELEASE-v1.0.0.md new file mode 100644 index 0000000..92f2d9f --- /dev/null +++ b/.claude/skills/swiftui-design-skill/RELEASE-v1.0.0.md @@ -0,0 +1,57 @@ +## SwiftUI Design Skill v1.0.0 + +> 一句话,一个能交付的 SwiftUI 界面。 + +首个正式版本。跨 agent 通用的 SwiftUI 前端视觉设计 skill——不 AI slop。 + +### ✨ 内置能力 + +| 能力 | 说明 | +|------|------| +| 反 AI Slop 六条铁律 | 6 条硬规则 + 详细参考文档 | +| 设计方向顾问 | 5 大设计流派 × N 种哲学,推荐 3 方向 | +| 品牌资产协议 | 5 步硬流程,从问到写 brand-spec.md | +| 五维评审 | 哲学一致性 / 视觉层级 / 细节执行 / 功能性 / 原创性 | +| 布局模式库 | 9 种常见布局,可复制 SwiftUI 代码 | +| 字体与色彩系统 | 层级字体 + 色彩 token + 设计系统模板 | +| Swift 扩展 | Color(hex:)、动画模式、SF Symbols 指南 | + +### 📦 安装 + +```bash +npx skills add wholiver/swiftui-design-skill -g -y +``` + +### 📁 文件结构 + +``` +swiftui-design-skill/ +├── SKILL.md ← 核心定义(278 行) +├── references/ +│ ├── anti-ai-slop.md ← 反 AI Slop 详细规则(268 行) +│ ├── layout-patterns.md ← 9 种布局模式(265 行) +│ ├── typography-color.md ← 字体层级 + 色彩系统(172 行) +│ ├── design-review.md ← 五维评审流程(151 行) +│ └── swift-extensions.md ← Color/Font/Animation 扩展(373 行) +└── templates/ + └── brand-spec.md ← 品牌规范模板(77 行) +``` + +### 🎯 与 swiftui-expert-skill 的区别 + +| | SwiftUI Design Skill | swiftui-expert-skill | +|---|---|---| +| 焦点 | 视觉设计、美学、品牌感 | 代码质量、性能、正确性 | +| 问题 | 「看起来怎么样?」 | 「代码写得对不对?」 | + +### 📋 Changelog + +- ✅ 反 AI Slop 六条铁律 + 详细参考 +- ✅ 设计方向顾问(5 流派) +- ✅ 品牌资产协议(5 步流程) +- ✅ 五维评审系统 +- ✅ 9 种布局模式 + 可复制代码 +- ✅ 字体层级与色彩系统 +- ✅ Swift 扩展(Color/Font/Animation) +- ✅ 品牌规范模板 +- ✅ MIT License diff --git a/.claude/skills/swiftui-design-skill/SKILL.md b/.claude/skills/swiftui-design-skill/SKILL.md new file mode 100644 index 0000000..b9a7235 --- /dev/null +++ b/.claude/skills/swiftui-design-skill/SKILL.md @@ -0,0 +1,216 @@ +--- +name: swiftui-design-skill +description: SwiftUI frontend visual design skill. Creates beautiful, distinctive iOS/macOS interfaces that avoid generic AI slop patterns. Covers design direction, layout systems, typography, color, spacing, brand integration, and design review. Use when designing new SwiftUI views, reviewing UI quality, creating iOS prototypes, choosing visual styles, improving app aesthetics, or when the UI looks generic or AI-generated. +license: MIT +model: sonnet +metadata: + author: wholiver + version: "1.0.0" +--- + +# SwiftUI Design Skill + +> **TL;DR**: Design SwiftUI apps that look intentionally crafted, not AI-generated. Follow the 6 Anti-Slop Rules, use the Design Direction workflow when requirements are vague, and always pass the 5-Dimension Review before shipping. + +## When to Use + +- Designing new SwiftUI views or screens from scratch +- Reviewing or improving existing UI quality +- Creating iOS app prototypes or mockups +- Choosing a visual style or design direction +- Integrating brand assets into SwiftUI +- When someone says "the UI looks generic" or "make it look better" +- When building a new feature that needs visual design + +## When NOT to Use + +- Pure backend / API work with no UI +- Fixing SwiftUI compilation errors (use swiftui-expert-skill instead) +- Performance optimization of existing views (use swiftui-expert-skill) +- Accessibility compliance only (use swiftui-expert-skill's accessibility topic) + +## Prerequisites + +- Xcode 16+ with iOS 18+ / macOS 15+ SDK +- SwiftUI framework (not UIKit unless explicitly requested) +- Familiarity with SwiftUI view hierarchy and modifiers + +--- + +## Core Design Philosophy + +These 6 principles guide every design decision. They are distilled from real-world design practice and anti-patterns observed in AI-generated UI. + +### Principle 1: Start From Context, Never Blank + +Before designing any screen, ask: +- Is there an existing design system, UI kit, or Figma file? +- Is there an existing codebase with established visual language? +- Are there brand guidelines or a style guide? + +If no context exists, create a **brand-spec.md** first (see Brand Asset Protocol below). + +### Principle 2: Junior Designer Mode — Hypothesize Early, Iterate Fast + +Don't wait for the "perfect" design. Show hypotheses with placeholders early: +- Gray boxes for images are **better** than bad AI-generated SVGs +- System fonts with good spacing > custom fonts with bad spacing +- A rough but honest layout > a polished but generic one + +### Principle 3: Offer Variations, Not Finals + +Always present **2-3 distinct design directions**, not one "final answer." Each variation should: +- Represent a different design philosophy (e.g., editorial, functional, expressive) +- Have a clear rationale for why it works +- Be visually distinct — not just color swaps + +### Principle 4: Placeholder > Bad Implementation + +If you cannot source a real asset (icon, image, illustration): +- Use a clean gray placeholder with clear labeling +- Never use AI-generated SVG clipart +- Never use emoji as icon substitutes in production UI +- For icons, prefer SF Symbols (Apple's built-in icon library) + +### Principle 5: System First, Don't Fill + +Every element must earn its place. Before adding any UI element, ask: +- Does this serve the user's goal? +- Can this be combined with something else? +- What happens if I remove it? + +Whitespace is a feature, not emptiness. + +### Principle 6: Anti-AI-Slop + +These patterns are **banned** in production UI. See `references/anti-ai-slop.md` for detailed rules. + +**Banned visual patterns:** +- ❌ Purple-to-blue gradient backgrounds +- ❌ Emoji as functional icons +- ❌ Rounded cards with left-border accent color +- ❌ Generic "hero section" with centered text over gradient +- ❌ Inter/Roboto as display fonts on Apple platforms +- ❌ Neon-on-dark "cyber" aesthetic (#0D1117 base) +- ❌ Symmetric 3-column feature grids +- ❌ AI-drawn SVG illustrations or CSS silhouettes + +**What to do instead:** +- ✅ Single warm accent color on neutral background +- ✅ SF Symbols for icons, or custom icon set +- ✅ Serif display font (e.g., New York, Georgia) for headings +- ✅ Restrained info density — let content breathe +- ✅ One signature detail at 120% effort per screen +- ✅ Real photography or clean placeholders + +--- + +## Brand + +If `docs/design/brand-spec.md` exists, follow it and skip direction work. +Only if no spec exists, write a short one (1 accent, neutrals, fonts, 8pt grid). No web search, no asset downloads. + +--- + +## Design Direction Workflow + +When requirements are vague or the user asks "what style should we use?", follow this workflow: + +### Phase 1: Understand Context (5 min) +1. Ask: What is the app? Who is the user? What platform (iOS/macOS/watchOS)? +2. Ask: Any reference apps or websites they admire? +3. Check: Existing design system or brand assets? + +### Phase 2: Recommend 3 Directions +Only when the user asks for a new visual direction. Otherwise reuse the brand spec. +Present 3 distinct design philosophies from different schools: + +| School | Example Directions | +| --------------- | ------------------------------------------- | +| **Information** | Clean data-first, chart-heavy, Bloomberg-style | +| **Editorial** | Magazine layout, serif typography, generous whitespace | +| **Expressive** | Bold color, asymmetric layout, motion-forward | +| **Functional** | Dense utility, monospace accents, minimal decoration | +| **Warm Minimal** | Soft neutrals, rounded shapes, subtle texture | + +### Phase 3: Present Visual Anchors +For each direction, describe: +- Color palette (3-5 colors with hex values) +- Typography pairing (display + body) +- Layout density (comfortable / compact / spacious) +- Signature detail (one standout element per screen) + +### Phase 4: Let User Choose +Present options clearly. After choice, lock in the design system. + +--- + +## SwiftUI Design Workflow + +### Step 1: Define the Design System (Before Coding) + +Create or confirm design tokens (colors, fonts, 8pt spacing, radii) before coding. + +### Step 2: Build the Layout Hierarchy + +Follow the layout patterns in `references/layout-patterns.md`: +- Start with the primary content container +- Use `VStack` for vertical flow, `HStack` for horizontal +- Use `LazyVGrid` / `LazyHGrid` for grid layouts +- Use `ScrollView` with `.scrollTargetBehavior(.paging)` for paged content +- Avoid `GeometryReader` — prefer `containerRelativeFrame` (iOS 17+) + +### Step 3: Apply Typography Hierarchy + +Follow `references/typography-color.md`: +- **Display** (screen titles): Serif font, 28-34pt, bold +- **Heading** (section titles): Sans-serif, 20-24pt, semibold +- **Body** (content text): Sans-serif, 15-17pt, regular +- **Caption** (labels, metadata): Sans-serif, 12-13pt, secondary color + +### Step 4: Add the Signature Detail + +Each screen should have ONE element that goes 120% — a custom animation, a unique layout choice, a distinctive color moment. This is what makes the design memorable. + +Examples: +- A custom pull-to-refresh with brand-colored animation +- A card that expands with a spring animation on tap +- A header that uses the brand's display font at an oversized scale +- A subtle parallax effect on scroll + +### Step 5: Review Against Checklist + +Self-check against the Validation Checklist below. Take screenshots or open +`references/design-review.md` only if the user asks for a review. + +--- + +## Quick Reference: Topic Router + +Read files in `references/` only when the task needs that topic; don't load them by default. + +| Topic | Reference File | +| ---------------------------- | ----------------------------------- | +| Anti-AI-Slop Rules (detail) | `references/anti-ai-slop.md` | +| Layout Patterns & Grids | `references/layout-patterns.md` | +| Typography & Color Systems | `references/typography-color.md` | +| 5-Dimension Design Review | `references/design-review.md` | +| Swift Extensions (required) | `references/swift-extensions.md` | +| Brand Spec Template | `templates/brand-spec.md` | + +--- + +## Validation Checklist + +Before considering any SwiftUI design complete, verify: + +- [ ] Design tokens defined (colors, fonts, spacing) +- [ ] No banned AI-slop patterns present +- [ ] Typography has clear hierarchy (3+ scales) +- [ ] Spacing follows 8pt grid +- [ ] Dark mode colors defined and tested +- [ ] One signature detail per screen +- [ ] SF Symbols used for icons (not emoji) +- [ ] Minimum 44×44pt tap targets +- [ ] No `GeometryReader` or `UIScreen.main.bounds` +- [ ] 5-Dimension Review score ≥ 7/10 on all dimensions diff --git a/.claude/skills/swiftui-design-skill/references/anti-ai-slop.md b/.claude/skills/swiftui-design-skill/references/anti-ai-slop.md new file mode 100644 index 0000000..9d5c1aa --- /dev/null +++ b/.claude/skills/swiftui-design-skill/references/anti-ai-slop.md @@ -0,0 +1,268 @@ +# Anti-AI-Slop Rules for SwiftUI + +These are specific patterns that make UI look "AI-generated" or generic. Each rule has a banned pattern, why it's bad, and what to do instead. + +## Rule 1: No Purple-Blue Gradient Backgrounds + +**Banned:** +```swift +LinearGradient( + colors: [.purple, .blue], + startPoint: .topLeading, + endPoint: .bottomTrailing +) +``` + +**Why:** Every AI tool defaults to this gradient. It signals "I didn't think about the design." + +**Instead:** Use a solid warm neutral background: +```swift +Color(hex: "FAFAF8") // Warm white +// or +Color(hex: "F5F3EF") // Light warm gray +``` + +If you must use a gradient, make it subtle and brand-specific: +```swift +LinearGradient( + colors: [Color(hex: "FFF5F0"), Color(hex: "FAFAF8")], + startPoint: .top, + endPoint: .bottom +) +``` + +## Rule 2: No Emoji as Icons + +**Banned:** +```swift +Text("🏠") // As a tab icon +Text("❤️") // As a like button +``` + +**Why:** Emoji rendering varies by platform, can't be styled, and looks unprofessional. + +**Instead:** Use SF Symbols: +```swift +Image(systemName: "house.fill") +Image(systemName: "heart.fill") +``` + +For custom needs, use a proper icon set (SF Symbols has 5,000+ icons). + +## Rule 3: No Rounded Cards with Left Border Accent + +**Banned:** +```swift +HStack { + Rectangle() + .fill(Color.purple) + .frame(width: 4) + VStack(alignment: .leading) { + Text("Title") + Text("Description") + } +} +.padding() +.background(Color(.systemBackground)) +.cornerRadius(12) +``` + +**Why:** This is the most common AI-generated card pattern. It's lazy and generic. + +**Instead:** Use proper card design: +```swift +VStack(alignment: .leading, spacing: DesignTokens.spaceS) { + Text("Title") + .font(.system(size: 18, weight: .semibold, design: .serif)) + Text("Description") + .font(.body) + .foregroundStyle(.secondary) +} +.padding(DesignTokens.spaceM) +.frame(maxWidth: .infinity, alignment: .leading) +.background(DesignTokens.surface) +.clipShape(RoundedRectangle(cornerRadius: DesignTokens.radiusM)) +``` + +## Rule 4: No Generic Hero Sections + +**Banned:** +```swift +VStack(spacing: 16) { + Text("Welcome to App") + .font(.largeTitle.bold()) + Text("The best app for everything") + .font(.title3) + .foregroundColor(.gray) + Button("Get Started") { } + .buttonStyle(.borderedProminent) +} +.frame(maxWidth: .infinity) +.padding(.vertical, 80) +.background( + LinearGradient(colors: [.purple, .blue], ...) +) +``` + +**Why:** This pattern is everywhere. It wastes the most valuable screen real estate on marketing copy nobody reads. + +**Instead:** Show actual content immediately: +```swift +VStack(alignment: .leading, spacing: DesignTokens.spaceL) { + // Small, tasteful header + Text("Today") + .font(.system(size: 28, weight: .bold, design: .serif)) + + // Real content + ForEach(items) { item in + ItemRow(item: item) + } +} +.padding(DesignTokens.spaceM) +``` + +## Rule 5: No Inter/Roboto on Apple Platforms + +**Banned:** +```swift +Text("Hello") + .font(.custom("Inter", size: 16)) +``` + +**Why:** Inter and Roboto are Google's fonts for Android/web. Using them on Apple platforms looks like a web app ported to native. + +**Instead:** Use Apple's system fonts: +```swift +// Default: SF Pro (sans-serif) +Text("Hello") + .font(.body) + +// Display: New York (serif) for headings +Text("Hello") + .font(.system(size: 28, weight: .bold, design: .serif)) + +// If you need a custom font, choose one that fits Apple's ecosystem: +// - Charter, Georgia, Iowan (serif alternatives) +// - Avenir, Futura, Gill Sans (geometric sans) +``` + +## Rule 6: No Neon-on-Dark Cyber Aesthetic + +**Banned:** +```swift +Color(hex: "0D1117") // GitHub dark +// with neon accent colors like #00FF88, #00D4FF +``` + +**Why:** This is the "I built a crypto dashboard" default. It's overused and hard to read. + +**Instead:** Use Apple's native dark mode colors: +```swift +// Let the system handle dark/light +Color(.systemBackground) +Color(.secondarySystemBackground) + +// Or define warm dark colors +Color(hex: "1C1C1E") // iOS dark background +Color(hex: "2C2C2E") // iOS dark secondary +``` + +## Rule 7: No Symmetric 3-Column Feature Grids + +**Banned:** +```swift +LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3)) { + FeatureCard(icon: "star", title: "Fast") + FeatureCard(icon: "shield", title: "Secure") + FeatureCard(icon: "bolt", title: "Easy") +} +``` + +**Why:** This is the SaaS landing page default. It doesn't work for native apps. + +**Instead:** Use asymmetric, content-driven layouts: +```swift +VStack(spacing: DesignTokens.spaceL) { + // Primary feature — large + FeaturedCard(item: featuredItem) + .frame(height: 200) + + // Secondary features — smaller, horizontal + HStack(spacing: DesignTokens.spaceM) { + SecondaryCard(item: item1) + SecondaryCard(item: item2) + } + .frame(height: 120) +} +``` + +## Rule 8: No AI-Drawn SVG Illustrations + +**Banned:** +- Inline SVG paths that draw abstract "people working" illustrations +- CSS-drawn silhouettes +- Any illustration that looks like it came from unDraw or Humaaans + +**Why:** These are immediately recognizable as AI-generated stock content. + +**Instead:** +- Use real photography (from Unsplash, Pexels, or the brand's own photos) +- Use SF Symbols at large scale as visual elements +- Use clean geometric shapes or patterns from the brand +- Use a simple placeholder with a note: "Replace with brand photo" + +## Rule 9: No Default Blue Tint Everywhere + +**Banned:** +```swift +// Everything uses the default blue accent +Button("Action") { } // Blue +Toggle("Setting", isOn: $value) // Blue +Link("Visit", url: url) // Blue +``` + +**Why:** Default blue means "I didn't customize anything." + +**Instead:** Define a custom accent color: +```swift +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .tint(DesignTokens.accent) // Your brand color + } + } +} +``` + +## Rule 10: No Random Spacing + +**Banned:** +```swift +.padding(.horizontal, 13) +.padding(.vertical, 7) +.frame(height: 43) +``` + +**Why:** Odd numbers look accidental. They create visual inconsistency. + +**Instead:** Use an 8pt grid: +```swift +.padding(.horizontal, DesignTokens.spaceM) // 16 +.padding(.vertical, DesignTokens.spaceS) // 8 +.frame(height: 44) // Minimum tap target +``` + +## Self-Check: Is Your UI AI Slop? + +Ask these questions: +1. Would this look at home on a generic SaaS landing page? +2. Are there purple-blue gradients? +3. Are emoji used as functional icons? +4. Is the spacing random or systematic? +5. Could you swap the brand name and it would still look the same? +6. Is there a "Welcome to [App]" hero section? +7. Are all cards the same rounded rectangle with a left accent border? + +If you answered "yes" to 3+ questions, the design needs work. diff --git a/.claude/skills/swiftui-design-skill/references/design-review.md b/.claude/skills/swiftui-design-skill/references/design-review.md new file mode 100644 index 0000000..a75af80 --- /dev/null +++ b/.claude/skills/swiftui-design-skill/references/design-review.md @@ -0,0 +1,151 @@ +# 5-Dimension SwiftUI Design Review + +A structured framework for evaluating SwiftUI UI quality. Score each dimension 1-10. + +## How to Use + +1. Take a screenshot of the UI +2. Evaluate each dimension below +3. Score honestly (most AI-generated UI scores 3-5) +4. Fix issues scoring < 7 +5. Re-evaluate after fixes + +--- + +## Dimension 1: Philosophy Alignment (1-10) + +Does the design follow a coherent philosophy, or is it a random collection of trends? + +| Score | Criteria | +| ----- | -------------------------------------------------------------------- | +| 1-3 | Random elements, no coherent style, looks like template mashup | +| 4-5 | Some consistency, but generic (could be any app) | +| 6-7 | Clear style direction, recognizable identity | +| 8-10 | Strong philosophical foundation, every element supports the vision | + +**Check:** +- [ ] Design follows one of the 5 schools (Informational / Editorial / Expressive / Functional / Warm Minimal) +- [ ] Every element supports the chosen philosophy +- [ ] No contradictory design choices (e.g., playful icons + corporate typography) +- [ ] The design would be recognizable if the brand name were removed + +--- + +## Dimension 2: Visual Hierarchy (1-10) + +Can the user immediately understand what's important? + +| Score | Criteria | +| ----- | -------------------------------------------------------------------- | +| 1-3 | Everything looks the same size/weight, no clear reading order | +| 4-5 | Some hierarchy, but competing elements | +| 6-7 | Clear primary/secondary/tertiary levels | +| 8-10 | Instant comprehension, natural eye flow, nothing competing | + +**Check:** +- [ ] Screen title is the largest text element +- [ ] Primary action is visually dominant +- [ ] Secondary information is visually subordinate +- [ ] Whitespace creates natural grouping +- [ ] The eye knows where to go first, second, third + +--- + +## Dimension 3: Craft Quality (1-10) + +Are the details right? This is where AI-generated UI fails most. + +| Score | Criteria | +| ----- | -------------------------------------------------------------------- | +| 1-3 | Random spacing, inconsistent sizing, alignment issues | +| 4-5 | Generally okay, but noticeable rough edges | +| 6-7 | Consistent spacing, good alignment, clean typography | +| 8-10 | Pixel-perfect, every detail intentional, feels handcrafted | + +**Check:** +- [ ] Spacing follows 8pt grid consistently +- [ ] Font sizes are from the defined scale (not random) +- [ ] Colors are from the design token system +- [ ] Corner radii are consistent +- [ ] Icons are the same weight/style throughout +- [ ] No orphaned text (single word on a line) +- [ ] Images have consistent aspect ratios + +--- + +## Dimension 4: Functionality (1-10) + +Does the design serve the user's actual needs? + +| Score | Criteria | +| ----- | -------------------------------------------------------------------- | +| 1-3 | Form over function, important actions hidden, confusing flow | +| 4-5 | Usable but not efficient, some friction points | +| 6-7 | Good usability, clear actions, logical flow | +| 8-10 | Effortless to use, anticipates needs, zero friction | + +**Check:** +- [ ] Primary action is immediately visible (not hidden in menus) +- [ ] Touch targets are ≥ 44×44pt +- [ ] Loading states are handled (no blank screens) +- [ ] Error states are handled with helpful messages +- [ ] Empty states guide the user on what to do +- [ ] Navigation is predictable (back goes back) +- [ ] Form inputs have clear labels and validation + +--- + +## Dimension 5: Originality (1-10) + +Does this look like a real product, or an AI template? + +| Score | Criteria | +| ----- | -------------------------------------------------------------------- | +| 1-3 | Clearly AI-generated, uses banned slop patterns, template-like | +| 4-5 | Generic but not offensive, could be any app | +| 6-7 | Has personality, some unique elements, feels intentional | +| 8-10 | Distinctive, memorable, would stand out in App Store | + +**Check:** +- [ ] No banned AI-slop patterns (see anti-ai-slop.md) +- [ ] At least one signature detail per screen +- [ ] Typography has personality (not just system defaults) +- [ ] Color palette is distinctive (not generic blue/purple) +- [ ] Layout is asymmetric where appropriate +- [ ] The design would not be confused with a template + +--- + +## Review Summary Template + +``` +## Design Review: [Screen Name] + +| Dimension | Score | Notes | +| ----------------- | ----- | ------------------------ | +| Philosophy | ?/10 | [specific feedback] | +| Visual Hierarchy | ?/10 | [specific feedback] | +| Craft Quality | ?/10 | [specific feedback] | +| Functionality | ?/10 | [specific feedback] | +| Originality | ?/10 | [specific feedback] | +| **Average** | **?/10** | | + +### Issues to Fix (score < 7) +1. [Issue]: [Specific fix] +2. [Issue]: [Specific fix] + +### What's Working Well +1. [Strength] +2. [Strength] +``` + +## Common Score Ranges + +| Range | Meaning | +| ------- | -------------------------------------------------- | +| 1-3 | AI slop — needs complete redesign | +| 4-5 | Generic — functional but no personality | +| 6-7 | Good — professional, minor issues | +| 8-10 | Excellent — distinctive, crafted, ship-ready | + +**Shipping threshold**: All dimensions ≥ 7, no dimension below 5. diff --git a/.claude/skills/swiftui-design-skill/references/layout-patterns.md b/.claude/skills/swiftui-design-skill/references/layout-patterns.md new file mode 100644 index 0000000..a514eaf --- /dev/null +++ b/.claude/skills/swiftui-design-skill/references/layout-patterns.md @@ -0,0 +1,265 @@ +# SwiftUI Layout Patterns + +Practical layout patterns for common SwiftUI design scenarios. Each pattern includes code and when to use it. + +## Core Layout Principles + +1. **Vertical flow first** — SwiftUI naturally flows top-to-bottom +2. **Semantic grouping** — Group related content, not just visual elements +3. **Progressive disclosure** — Show summary first, details on demand +4. **Content-driven sizing** — Let content determine heights, not arbitrary frames + +## Pattern 1: Screen Structure + +Every screen follows this skeleton: + +```swift +struct ScreenView: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: DesignTokens.spaceL) { + // 1. Header (small, tasteful) + ScreenHeader(title: "Screen Title") + + // 2. Primary content + PrimaryContentSection() + + // 3. Secondary content + SecondaryContentSection() + } + .padding(DesignTokens.spaceM) + } + .background(DesignTokens.background) + } +} +``` + +**Key rules:** +- Never use `GeometryReader` for basic layout — use `containerRelativeFrame` or `frame(maxWidth: .infinity)` +- Always wrap scrollable content in `ScrollView` +- Use `.scrollTargetBehavior(.paging)` for paged scrolling +- Use `.scrollClipDisabled()` if content needs to overflow scroll bounds + +## Pattern 2: Card Layout + +```swift +struct CardView: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .padding(DesignTokens.spaceM) + .frame(maxWidth: .infinity, alignment: .leading) + .background(DesignTokens.surface) + .clipShape(RoundedRectangle(cornerRadius: DesignTokens.radiusM)) + .shadow(color: .black.opacity(0.04), radius: 8, y: 2) + } +} +``` + +**Key rules:** +- Cards should have subtle shadow, not heavy borders +- Corner radius should be consistent (12-16pt for cards) +- Always use `clipShape(RoundedRectangle(...))` not `.cornerRadius()` +- Content inside cards should have clear hierarchy + +## Pattern 3: List Layout + +```swift +struct ItemListView: View { + let items: [Item] + + var body: some View { + LazyVStack(spacing: 0) { + ForEach(items) { item in + ItemRow(item: item) + + if item.id != items.last?.id { + Divider() + .padding(.leading, DesignTokens.spaceM) + } + } + } + .background(DesignTokens.surface) + .clipShape(RoundedRectangle(cornerRadius: DesignTokens.radiusM)) + } +} +``` + +**Key rules:** +- Use `LazyVStack` for moderate lists, `List` for very long lists +- Separators should be inset, not full-width +- Group related items visually (with section headers, not just dividers) + +## Pattern 4: Grid Layout + +```swift +// 2-column adaptive grid +let columns = [ + GridItem(.adaptive(minimum: 160), spacing: DesignTokens.spaceM) +] + +LazyVGrid(columns: columns, spacing: DesignTokens.spaceM) { + ForEach(items) { item in + GridItemView(item: item) + } +} +``` + +**Key rules:** +- Use `GridItem(.adaptive(minimum:))` for responsive grids +- Avoid fixed column counts — let content determine layout +- Never do symmetric 3-column feature grids (anti-slop rule) +- Grid items should have consistent heights within a row + +## Pattern 5: Header + Content + +```swift +struct ScreenHeader: View { + let title: String + let subtitle: String? + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.spaceXS) { + Text(title) + .font(.system(size: 28, weight: .bold, design: .serif)) + + if let subtitle { + Text(subtitle) + .font(.body) + .foregroundStyle(.secondary) + } + } + .padding(.bottom, DesignTokens.spaceS) + } +} +``` + +**Key rules:** +- Headers should be left-aligned (not centered) for most apps +- Use serif display font for main titles +- Keep headers compact — don't waste vertical space +- Large title navigation: use `.navigationBarTitleDisplayMode(.large)` only for root views + +## Pattern 6: Tab Bar Layout + +```swift +TabView { + HomeView() + .tabItem { + Label("Home", systemImage: "house") + } + + SearchView() + .tabItem { + Label("Search", systemImage: "magnifyingglass") + } + + ProfileView() + .tabItem { + Label("Profile", systemImage: "person") + } +} +.tint(DesignTokens.accent) +``` + +**Key rules:** +- 3-5 tabs maximum +- Use SF Symbols for tab icons +- Active tab uses the accent color +- Don't put settings in tabs — use a gear icon in the navigation bar + +## Pattern 7: Modal / Sheet + +```swift +.sheet(isPresented: $showSheet) { + NavigationStack { + SheetContent() + .navigationTitle("Details") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showSheet = false } + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) +} +``` + +**Key rules:** +- Always wrap sheets in `NavigationStack` +- Use `.presentationDetents` for half-sheets +- Provide a clear dismiss action +- Use `.presentationDragIndicator(.visible)` for discoverability + +## Pattern 8: Empty State + +```swift +struct EmptyStateView: View { + let icon: String + let title: String + let message: String + + var body: some View { + ContentUnavailableView { + Label(title, systemImage: icon) + } description: { + Text(message) + } + } +} +``` + +**Key rules:** +- Use `ContentUnavailableView` (iOS 17+) for empty states +- Don't use emoji — use SF Symbols +- Keep messaging concise and helpful +- Include a call-to-action if appropriate + +## Pattern 9: Loading State + +```swift +struct LoadingView: View { + var body: some View { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(DesignTokens.background) + } +} +``` + +**Key rules:** +- Use `ProgressView()` for simple loading +- For skeleton screens, use `.redacted(reason: .placeholder)` +- Never show a blank screen during loading +- Consider shimmer effects for premium feel + +## Pattern 10: Responsive Layout (iPhone + iPad) + +```swift +struct ResponsiveView: View { + @Environment(\.horizontalSizeClass) var sizeClass + + var body: some View { + if sizeClass == .compact { + // iPhone layout + VStack { ... } + } else { + // iPad layout + HStack { ... } + } + } +} +``` + +**Key rules:** +- Use `horizontalSizeClass` for layout adaptation +- Don't just scale up — use the extra space meaningfully +- iPad should show more content, not bigger content +- Use `NavigationSplitView` for multi-column on iPad diff --git a/.claude/skills/swiftui-design-skill/references/swift-extensions.md b/.claude/skills/swiftui-design-skill/references/swift-extensions.md new file mode 100644 index 0000000..a41cdf2 --- /dev/null +++ b/.claude/skills/swiftui-design-skill/references/swift-extensions.md @@ -0,0 +1,373 @@ +# Swift Extensions for SwiftUI Design + +These extensions are required for the code examples in this skill to compile. Copy them into your project. + +## Color Extensions + +### Color(hex:) + +Converts hex string to SwiftUI Color. Supports 3, 4, 6, and 8 character hex strings. + +```swift +import SwiftUI + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} +``` + +### Color(light:dark:) — Adaptive Colors + +Creates colors that automatically adapt to light/dark mode. + +```swift +extension Color { + init(light lightHex: String, dark darkHex: String) { + self.init(uiColor: UIColor( + light: UIColor(Color(hex: lightHex)), + dark: UIColor(Color(hex: darkHex)) + )) + } +} + +extension UIColor { + convenience init(light: UIColor, dark: UIColor) { + self.init { traitCollection in + traitCollection.userInterfaceStyle == .dark ? dark : light + } + } +} +``` + +### Predefined Design Colors + +```swift +extension Color { + // Warm accent (coral) + static let appAccent = Color(hex: "E85D3A") + static let appAccentLight = Color(hex: "FFF0EB") + static let appAccentDark = Color(hex: "C44A2E") + + // Warm neutrals + static let appBackground = Color(light: "FAFAF8", dark: "1C1C1E") + static let appSurface = Color(light: "FFFFFF", dark: "2C2C2E") + static let appTextPrimary = Color(light: "1A1A1A", dark: "F2F2F7") + static let appTextSecondary = Color(light: "6B6B6B", dark: "8E8E93") + static let appDivider = Color(light: "E5E5E5", dark: "38383A") +} +``` + +--- + +## Font Extensions + +### Correct "New York" Font Usage + +SwiftUI has built-in serif support. Use `design: .serif` instead of `Font.custom("New York", ...)`: + +```swift +// ✅ CORRECT — iOS 17+ +Font.system(size: 28, weight: .bold, design: .serif) + +// ✅ CORRECT — Shorthand +Font.system(.title, design: .serif).bold() + +// ❌ WRONG — "New York" is not a custom font name +Font.custom("New York", size: 28) +``` + +### AppFont Enum (Corrected) + +```swift +enum AppFont { + // Display: Serif for personality (use .serif design) + static let display = Font.system(size: 28, weight: .bold, design: .serif) + static let displaySmall = Font.system(size: 22, weight: .bold, design: .serif) + + // Heading: Sans-serif for clarity + static let heading = Font.system(size: 20, weight: .semibold, design: .default) + static let subheading = Font.system(size: 17, weight: .medium, design: .default) + + // Body: System default + static let body = Font.system(size: 16, weight: .regular, design: .default) + static let bodySmall = Font.system(size: 14, weight: .regular, design: .default) + + // Caption: Smaller, lighter + static let caption = Font.system(size: 12, weight: .regular, design: .default) + static let captionMedium = Font.system(size: 12, weight: .medium, design: .default) +} +``` + +### Using Custom Fonts (If Needed) + +If you must use a custom font (e.g., from a brand), use the PostScript name: + +```swift +// Find the PostScript name in Font Book.app → Select font → Info tab +Font.custom("NewYork-Regular", size: 17) +Font.custom("NewYork-Bold", size: 28) +``` + +--- + +## Animation Design Patterns + +### Spring Animation (Card Expand) + +```swift +struct ExpandableCard: View { + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Title") + .font(AppFont.heading) + + if isExpanded { + Text("Detailed content that appears with a spring animation.") + .font(AppFont.body) + .foregroundStyle(.secondary) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.appSurface) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .onTapGesture { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + isExpanded.toggle() + } + } + } +} +``` + +### Pull-to-Refresh with Brand Color + +```swift +struct BrandRefreshable: View { + @State private var items: [String] = ["Item 1", "Item 2"] + + var body: some View { + List(items, id: \.self) { item in + Text(item) + } + .refreshable { + // Simulate refresh + try? await Task.sleep(for: .seconds(1)) + } + .tint(Color.appAccent) // Brand-colored refresh indicator + } +} +``` + +### Subtle Parallax on Scroll + +```swift +struct ParallaxHeader: View { + var body: some View { + ScrollView { + GeometryReader { geo in + let offset = geo.frame(in: .global).minY + Image("header-photo") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 300 + max(0, -offset)) + .offset(y: max(0, -offset) * 0.5) // Parallax factor + .clipped() + } + .frame(height: 300) + + // Content below + VStack(spacing: 16) { + Text("Content") + } + .padding() + } + } +} +``` + +### Tab Selection Animation + +```swift +struct AnimatedTabBar: View { + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + HomeView() + .tabItem { Label("Home", systemImage: "house") } + .tag(0) + + SearchView() + .tabItem { Label("Search", systemImage: "magnifyingglass") } + .tag(1) + } + .tint(Color.appAccent) + } +} +``` + +--- + +## SF Symbols Selection Guide + +### Weight and Scale + +SF Symbols have 9 weights: ultralight, light, thin, regular, medium, semibold, bold, heavy, black. + +```swift +// Use weight to match text hierarchy +Image(systemName: "star.fill") + .font(.system(size: 20, weight: .semibold)) // Match heading weight + +// Use scale for size adjustment +Image(systemName: "star.fill") + .imageScale(.small) // 16pt + .imageScale(.medium) // 20pt (default) + .imageScale(.large) // 28pt +``` + +### Fill vs Outline + +Use `.fill` variants for: +- Active/selected states +- Tab bar active icons +- Toggle states + +Use outline (default) for: +- Inactive states +- Navigation bar buttons +- Decorative icons + +```swift +// Active state +Image(systemName: selected ? "heart.fill" : "heart") + .foregroundStyle(selected ? Color.appAccent : Color.appTextSecondary) +``` + +### Common SF Symbols by Category + +| Category | Symbols | +| ---------- | ------------------------------------------------------------------------------------------ | +| Navigation | `chevron.left`, `chevron.right`, `chevron.down`, `arrow.left`, `arrow.right` | +| Actions | `plus`, `minus`, `pencil`, `trash`, `square.and.arrow.up`, `doc.on.doc` | +| Status | `checkmark.circle.fill`, `xmark.circle.fill`, `exclamationmark.triangle.fill`, `info.circle` | +| Media | `play.fill`, `pause.fill`, `forward.fill`, `backward.fill`, `speaker.wave.2.fill` | +| People | `person`, `person.fill`, `person.2`, `person.circle.fill`, `person.badge.plus` | +| Objects | `star`, `star.fill`, `heart`, `heart.fill`, `bell`, `bell.fill`, `gear`, `gearshape` | + +### Rendering Mode + +```swift +// Monochrome (default) — uses foregroundStyle +Image(systemName: "star.fill") + .foregroundStyle(Color.appAccent) + +// Hierarchical — adds depth with opacity +Image(systemName: "star.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(Color.appAccent) + +// Palette — multiple colors +Image(systemName: "star.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(Color.appAccent, Color.appAccentLight) + +// Multicolor — uses original symbol colors +Image(systemName: "star.fill") + .symbolRenderingMode(.multicolor) +``` + +--- + +## Image & Asset Handling + +### AsyncImage (Loading Remote Images) + +```swift +struct RemoteImage: View { + let url: URL? + + var body: some View { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + // Fallback: clean placeholder + Rectangle() + .fill(Color.appDivider) + .overlay { + Image(systemName: "photo") + .foregroundStyle(Color.appTextSecondary) + } + case .empty: + // Loading: subtle shimmer or progress + Rectangle() + .fill(Color.appDivider) + .overlay { + ProgressView() + } + @unknown default: + EmptyView() + } + } + } +} +``` + +### Placeholder Best Practices + +```swift +// ✅ GOOD: Clean placeholder with icon +Rectangle() + .fill(Color.appDivider) + .overlay { + Image(systemName: "photo") + .font(.system(size: 32)) + .foregroundStyle(Color.appTextSecondary.opacity(0.5)) + } + .clipShape(RoundedRectangle(cornerRadius: 12)) + +// ❌ BAD: Emoji placeholder +Text("🖼️") + +// ❌ BAD: AI-generated SVG +// (inline SVG paths) +``` + +### Image Caching Strategy + +For production apps, consider: +- `Kingfisher` (popular, well-maintained) — `KFImage(url)` +- `Nuke` (high performance) +- Or roll your own with `NSCache` + `AsyncImage` + +Never load images synchronously on the main thread. diff --git a/.claude/skills/swiftui-design-skill/references/typography-color.md b/.claude/skills/swiftui-design-skill/references/typography-color.md new file mode 100644 index 0000000..7f51115 --- /dev/null +++ b/.claude/skills/swiftui-design-skill/references/typography-color.md @@ -0,0 +1,172 @@ +> **Note**: Requires `swift-extensions.md` for `Color(hex:)` and `Color(light:dark:)` implementations. + +# Typography & Color Systems for SwiftUI + +## Typography System + +### Font Hierarchy + +| Level | Size | Weight | Font | Use For | +| -------- | ------- | ---------- | ---------- | -------------------------- | +| Display | 28-34pt | Bold | New York | Screen titles | +| Heading | 20-24pt | Semibold | SF Pro | Section titles | +| Subhead | 17pt | Medium | SF Pro | Card titles, list items | +| Body | 15-17pt | Regular | SF Pro | Paragraph text | +| Caption | 12-13pt | Regular | SF Pro | Labels, metadata, timestamps | +| Footnote | 11pt | Regular | SF Pro | Legal text, disclaimers | + +### Font Implementation + +```swift +enum AppFont { + // Display: Serif for personality (use .serif design) + static let display = Font.system(size: 28, weight: .bold, design: .serif) + static let displaySmall = Font.system(size: 22, weight: .bold, design: .serif) + + // Heading: Sans-serif for clarity + static let heading = Font.system(size: 20, weight: .semibold, design: .default) + static let subheading = Font.system(size: 17, weight: .medium, design: .default) + + // Body: System default + static let body = Font.system(size: 16, weight: .regular, design: .default) + static let bodySmall = Font.system(size: 14, weight: .regular, design: .default) + + // Caption: Smaller, lighter + static let caption = Font.system(size: 12, weight: .regular, design: .default) + static let captionMedium = Font.system(size: 12, weight: .medium, design: .default) +} +``` + +### Typography Rules + +1. **Maximum 2 font families** per screen (e.g., New York + SF Pro) +2. **Maximum 4 font sizes** per screen (e.g., display + heading + body + caption) +3. **Line height**: 1.4× for body text, 1.2× for headings +4. **Line length**: 45-75 characters per line for body text +5. **Never** use Inter, Roboto, or Poppins on Apple platforms +6. **Always** use `.font()` modifier, not raw `UIFont`/`NSFont` + +### Dynamic Type Support + +```swift +// Use @ScaledMetric for size-dependent values +@ScaledMetric(relativeTo: .body) var iconSize: CGFloat = 20 + +Image(systemName: "star") + .font(.system(size: iconSize)) +``` + +### When to Use Serif (New York) + +- App title / branding +- Article/blog post headings +- Editorial content +- Landing page hero text +- When you want to feel "premium" or "literary" + +**Don't use serif for:** +- Body text (harder to read at small sizes) +- Data-heavy interfaces +- Technical/admin UIs + +--- + +## Color System + +### Color Palette Structure + +```swift +enum AppColors { + // Accent — 1 warm color that defines the brand + static let accent = Color(hex: "E85D3A") // Warm coral + static let accentLight = Color(hex: "FFF0EB") // Very light accent + static let accentDark = Color(hex: "C44A2E") // Dark accent + + // Neutrals — warm undertone, not pure gray + static let background = Color(light: "FAFAF8", dark: "1C1C1E") + static let surface = Color(light: "FFFFFF", dark: "2C2C2E") + static let textPrimary = Color(light: "1A1A1A", dark: "F2F2F7") + static let textSecondary = Color(light: "6B6B6B", dark: "8E8E93") + static let textTertiary = Color(light: "999999", dark: "636366") + static let divider = Color(light: "E5E5E5", dark: "38383A") + + // Semantic — contextual colors + static let success = Color(hex: "34C759") + static let warning = Color(hex: "FF9500") + static let error = Color(hex: "FF3B30") + static let info = Color(hex: "007AFF") +} +``` + +### Color Rules + +1. **One accent color** — not a rainbow. One warm color defines the brand. +2. **Warm neutrals** — grays should have a warm undertone (slightly yellow/red), not blue. +3. **Semantic colors** — use context-based names (`textPrimary`) not appearance-based (`darkGray`). +4. **Dark mode** — every color must have a light AND dark variant. +5. **Contrast** — minimum 4.5:1 for body text, 3:1 for large text (WCAG AA). +6. **No pure black** — use `#1A1A1A` or `#1C1C1E`, not `#000000`. +7. **No pure white** — use `#FAFAF8` or `#FFFFFF` with slight warmth. + +### Dark Mode Implementation + +```swift +extension Color { + init(light lightHex: String, dark darkHex: String) { + self.init(uiColor: UIColor( + light: UIColor(Color(hex: lightHex)), + dark: UIColor(Color(hex: darkHex)) + )) + } +} + +extension UIColor { + convenience init(light: UIColor, dark: UIColor) { + self.init { traitCollection in + traitCollection.userInterfaceStyle == .dark ? dark : light + } + } +} +``` + +### Accent Color Usage + +``` +Primary actions: accent (buttons, links, active states) +Hover/pressed: accentDark (interactive feedback) +Selection: accentLight (background highlight) +Decorative: accent at 10-20% opacity (subtle accents) +``` + +### Color Anti-Patterns + +| Banned | Why | Instead | +| ----------------------------- | -------------------------- | -------------------------- | +| Purple-blue gradient | AI slop default | Solid warm accent | +| Neon green (#00FF88) | Crypto/dashboard cliché | Muted success green | +| Pure black background (#000) | Too harsh, no depth | Dark gray (#1C1C1E) | +| Rainbow accent colors | No visual identity | One consistent accent | +| Gray text on gray background | Low contrast | Ensure 4.5:1 ratio | +| Random hex values | Inconsistent | Use design token system | + +### Spacing System (8pt Grid) + +```swift +enum Spacing { + static let xxs: CGFloat = 2 // Hairline gaps + static let xs: CGFloat = 4 // Tight spacing + static let s: CGFloat = 8 // Default small + static let m: CGFloat = 16 // Default medium + static let l: CGFloat = 24 // Section spacing + static let xl: CGFloat = 32 // Large section spacing + static let xxl: CGFloat = 48 // Screen-level spacing + static let xxxl: CGFloat = 64 // Hero spacing +} +``` + +**Rules:** +- All spacing values must be multiples of 4 (preferably 8) +- Horizontal padding: 16pt (phone), 20-24pt (tablet) +- Vertical spacing between sections: 24-32pt +- Vertical spacing within sections: 8-16pt +- Minimum touch target: 44×44pt diff --git a/.claude/skills/swiftui-design-skill/templates/brand-spec.md b/.claude/skills/swiftui-design-skill/templates/brand-spec.md new file mode 100644 index 0000000..f2413ad --- /dev/null +++ b/.claude/skills/swiftui-design-skill/templates/brand-spec.md @@ -0,0 +1,77 @@ +# Brand Specification Template + +Use this template when creating a brand-spec.md for your project. Fill in each section. + +## Brand Name +[Your Brand Name] + +## Colors + +### Primary Accent +- **Color**: [e.g., Warm Coral] +- **Hex**: [e.g., #E85D3A] +- **Usage**: Primary buttons, active states, links, key UI elements + +### Accent Light +- **Hex**: [e.g., #FFF0EB] +- **Usage**: Selection highlights, hover states, subtle backgrounds + +### Accent Dark +- **Hex**: [e.g., #C44A2E] +- **Usage**: Pressed states, dark mode accent + +### Neutrals +| Token | Light Mode | Dark Mode | Usage | +| --------------- | ---------- | --------- | ----------------------- | +| Background | #FAFAF8 | #1C1C1E | Main background | +| Surface | #FFFFFF | #2C2C2E | Cards, elevated surfaces | +| Text Primary | #1A1A1A | #F2F2F7 | Main text | +| Text Secondary | #6B6B6B | #8E8E93 | Subtitles, metadata | +| Divider | #E5E5E5 | #38383A | Separators | + +### Semantic Colors +| Token | Hex | Usage | +| ------- | ------- | ---------------- | +| Success | #34C759 | Confirmations | +| Warning | #FF9500 | Cautions | +| Error | #FF3B30 | Errors, destructive actions | +| Info | #007AFF | Informational | + +## Typography + +### Display Font +- **Font**: [e.g., SF Serif (system serif)] +- **Usage**: Screen titles, hero text + +### Body Font +- **Font**: [e.g., SF Pro (system default)] +- **Usage**: All body text, labels, buttons + +### Type Scale +| Level | Size | Weight | Font | +| -------- | ------- | -------- | ------------- | +| Display | 28pt | Bold | Serif | +| Heading | 20pt | Semibold | Sans-serif | +| Body | 16pt | Regular | Sans-serif | +| Caption | 12pt | Regular | Sans-serif | + +## Spacing +- **Grid**: 8pt base +- **Horizontal padding**: 16pt (phone), 20pt (tablet) +- **Section spacing**: 24pt +- **Element spacing**: 8-16pt + +## Corner Radius +| Level | Value | Usage | +| ------ | ----- | ------------------- | +| Small | 8pt | Buttons, small cards | +| Medium | 12pt | Cards, inputs | +| Large | 16pt | Sheets, modals | + +## Logo Usage +- **Minimum size**: [e.g., 44×44pt] +- **Clear space**: [e.g., 16pt on all sides] +- **Variations**: [Light / Dark / Monochrome] + +## Notes +[Any additional brand guidelines, voice/tone, or design principles] diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml new file mode 100644 index 0000000..b0f2e05 --- /dev/null +++ b/.github/workflows/ci-release.yml @@ -0,0 +1,36 @@ +name: Publicar release + +on: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: retrox-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + test: + uses: ./.github/workflows/tests.yml + + release: + needs: test + runs-on: macos-15 + timeout-minutes: 60 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - run: npm ci --ignore-scripts + - name: Publicar versão e pacote + run: npm run release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7421d31 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,50 @@ +name: Testes + +on: + pull_request: + push: + branches-ignore: [main] + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + test: + runs-on: macos-15 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Registrar commit e toolchain + run: | + git rev-parse HEAD + xcodebuild -version + swift --version + - name: Executar testes + shell: bash + run: | + mkdir -p test-results + scripts/test.sh 2>&1 | tee test-results/unit.log + - name: Integração com emulador falso (sem janela) + shell: bash + run: scripts/integration-launch.sh --process-only 2>&1 | tee test-results/process-integration.log + - name: Guardar logs de testes + if: always() + uses: actions/upload-artifact@v4 + with: + name: RetroX-test-results + path: test-results/ + if-no-files-found: ignore + - name: Validar empacotamento + run: scripts/package-app.sh 0.0.0 + - name: Verificar pacote extraído + run: scripts/verify-package.sh 0.0.0 + - name: Disponibilizar pacote para download + uses: actions/upload-artifact@v4 + with: + name: RetroX-macos-arm64 + path: | + dist/RetroX-0.0.0-macos-arm64.zip + dist/SHA256SUMS.txt + if-no-files-found: error diff --git a/.gitignore b/.gitignore index f796b69..68dd98d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ local/ # Artefatos gerados por scripts /dist/ + +# Dependências da automação de release +node_modules/ diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..1e5ef4f --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,16 @@ +{ + "branches": ["main"], + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + ["@semantic-release/exec", { "prepareCmd": "scripts/package-app.sh ${nextRelease.version}" }], + ["@semantic-release/github", { + "assets": [ + { "path": "dist/RetroX-*.zip", "label": "RetroX para macOS Apple Silicon" }, + { "path": "dist/SHA256SUMS.txt", "label": "SHA-256" } + ], + "successComment": false, + "failComment": false + }] + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..6e7cf68 --- /dev/null +++ b/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "RetroX", + platforms: [.macOS(.v14)], + products: [ + .executable(name: "RetroX", targets: ["RetroXApp"]), + .executable(name: "rx-launch-probe", targets: ["RXLaunchProbe"]), + .executable(name: "rx-fake-emulator", targets: ["RXFakeEmulator"]), + ], + targets: [ + // Regras de negócio, sem SwiftUI e sem SwiftData: compila e testa só com as Command Line Tools. + .target(name: "RetroXCore"), + .executableTarget(name: "RetroXApp", dependencies: ["RetroXCore"], resources: [.copy("Resources")]), + // Ferramentas de desenvolvimento; não fazem parte do app distribuído. + .executableTarget(name: "RXLaunchProbe", dependencies: ["RetroXCore"]), + .executableTarget(name: "RXFakeEmulator"), + .testTarget(name: "RetroXCoreTests", dependencies: ["RetroXCore"]), + ] +) diff --git a/README.md b/README.md index 49e382c..84b6301 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,187 @@ -# ConsoleShelf — pacote de especificação para Claude Code -Contém: +
+ RetroX + RetroX +

Seus jogos, seus emuladores, uma só biblioteca.

+
-- `PROMPT_CLAUDE_CODE.md`: instruções de primeira sessão em Plan Mode. -- `docs/PRD.md`: produto, escopo, requisitos e critérios de aceite. -- `docs/adr/`: decisões de arquitetura propostas (não finalizadas). -- `ConsoleShelf_PROMPT_COMPLETO.md` (também disponível separadamente): tudo em um único texto para copiar e colar. +
+ Swift + macOS +
+ +
+ Status do workflow de testes + Versão da release + Licença GPL-3.0 +
+ +
+ Atividade semanal de commits + Estrelas no GitHub +
+ +

+ Instalação · + Como usar · + Desenvolvimento · + Documentação +

+ +--- + +RetroX é um launcher nativo para macOS que reúne seus jogos em uma biblioteca visual e os abre nos emuladores que você já tem instalados. Navegue pelo teclado ou controle, personalize perfis e temas, e volte à biblioteca quando terminar de jogar. + +O RetroX não inclui emuladores, jogos, BIOS ou chaves. Ele lê suas pastas de jogos sem copiar, mover ou alterar os arquivos. + +

+ Tela inicial do RetroX com uma prateleira de jogos fictícios no tema Rayo Paradise escuro +
+ Tela inicial com uma biblioteca de demonstração. +

+ +## Destaques + +- **Biblioteca visual:** navegue por prateleira, carrossel, grade ou lista e encontre seus jogos por busca e console. +- **Perfis pessoais:** cada pessoa mantém seus próprios favoritos, recentes e preferências. +- **Feito para controle:** percorra o aplicativo pelo controle ou teclado, com glifos para Xbox, PlayStation e Switch. +- **Temas para escolher:** personalize cores, fonte e animações com variantes claras e escuras. +- **Seus emuladores:** configure o PCSX2, abra o jogo e volte ao RetroX ao terminar. + +### Temas para o seu jeito de jogar + +| Rayo Paradise · claro | Gruvbox · escuro | +| :---: | :---: | +| RetroX com o tema Rayo Paradise claro | RetroX com o tema Gruvbox escuro | + +## Instalação + +### macOS (Apple Silicon) + +**[Baixar a última versão](https://github.com/oLuqueJs/RetroX/releases/latest)** + +Você precisa do macOS 14 ou superior e dos emuladores instalados por você. Hoje o RetroX suporta o **PCSX2** (PS2); o Dolphin (GameCube/Wii) está planejado. + +1. Baixe `RetroX-.zip` na [página de Releases](https://github.com/oLuqueJs/RetroX/releases) e confira o `SHA256SUMS.txt`. +2. Descompacte e mova o `RetroX.app` para `/Applications`. +3. Na primeira vez, abra com o botão direito > **Abrir**. O app tem assinatura ad hoc e não é notarizado, então o Gatekeeper pede autorização. ## Como usar -1. Extraia este pacote no repositório vazio do projeto. -2. No terminal do macOS, execute `claude --permission-mode plan` (ou abra `claude` e use `/plan`). -3. Envie ao Claude Code: `Leia @PROMPT_CLAUDE_CODE.md, @docs/PRD.md e todos os @docs/adr/*.md. Produza o plano solicitado, sem implementar antes de minha aprovação.` -4. Revise o plano e **só depois** autorize a implementação, começando pela prova de conceito PCSX2. +1. Escolha (ou crie) um perfil em **Quem vai jogar?**. Cada perfil tem foto, tema, favoritos e recentes próprios. +2. Vá em **Emuladores**, ative o **PCSX2** e escolha a pasta dele: a que tem o `PCSX2.app` e as pastas de jogos (por exemplo `~/Emulators/ps2`, com `roms/` e `covers/`). O RetroX também usa as pastas de jogos, as capas (inclusive as que o PCSX2 baixa) e a BIOS que o próprio PCSX2 já conhece. +3. Em **Início**, navegue pela prateleira; em **Biblioteca**, veja todos os jogos em grade ou lista, com busca e filtro por console. Pressione **Return** (ou o botão de confirmar) para jogar. Quando o PCSX2 fecha, o RetroX volta para a frente. +4. Em **Detalhes**, troque a capa de um jogo ou busque informações na Wikipedia. +5. Em **Ajustes**, escolha tema, fonte, visualização da tela inicial (Prateleira, Carrossel ou Grade), animações e o tipo de controle. Cada perfil guarda os seus. + +### Controles + +| Ação | Teclado | Controle | +| --- | --- | --- | +| Navegar | Setas | Direcional ou analógico | +| Confirmar ou jogar | Enter | Botão de confirmar¹ | +| Voltar | Esc | Botão de voltar | +| Trocar de aba | `[` / `]` ou ⌘1–⌘4 | Gatilhos | +| Trocar prateleira ou console | Q / E | Botões superiores | +| Abrir detalhes | Espaço | Botão de detalhes | +| Favoritar | F | Botão de favorito | +| Buscar na biblioteca | `/` | — | +| Abrir ajustes | M ou ⌘, | Start | + +¹ Por padrão, o botão inferior confirma; no layout Nintendo, é o botão da direita. Em **Ajustes > Controle**, escolha os glifos de Xbox, PlayStation (DualSense) ou Switch. + +## Desenvolvimento + +Para compilar: Swift 6, com as Command Line Tools ou o Xcode. A persistência das próximas fases vai exigir o Xcode. + +```sh +scripts/check-toolchain.sh # o que o toolchain atual consegue (Xcode, SwiftData, Rosetta) +swift build # app, ferramentas e núcleo +scripts/bundle-app.sh debug # monta .build/RetroX.app (assinatura ad hoc) +open .build/RetroX.app +``` + +### Testes + +```sh +scripts/test.sh # testes unitários (Swift Testing) +scripts/integration-launch.sh --process-only # processo falso sem janela (também no CI) +scripts/integration-launch.sh # abre um emulador falso de verdade e confere o argv +RX_NETWORK=1 scripts/test.sh --filter MetadataNetworkTests # busca real na Wikipedia (opt-in) +``` + +Veja [a estratégia de testes](docs/TESTING.md) para mocks, validação do pacote e +inspeções opcionais de instalações de PCSX2, Azahar e Ryujinx. + +### Diagnóstico de launch + +Sem a interface, com o mesmo código do app: + +```sh +swift run rx-launch-probe --app /Applications/PCSX2.app --rom "/caminho/do/jogo.iso" --dry-run +``` + +Sem `--dry-run`, o emulador é aberto de verdade. + +### Marca + +O ícone, o tile e as assinaturas são gerados a partir de `docs/design/rayo/rayo.png`: + +```sh +swift scripts/brand-kit.swift +``` + +### Releases + +O workflow de testes do GitHub Actions roda em pull requests, em pushes para branches fora de `main` e em execuções manuais. O workflow de release reutiliza esses testes no commit enviado a `main`. Depois deles, o semantic-release analisa os commits convencionais, cria a tag e publica uma GitHub Release quando há mudanças que justificam uma nova versão, com o ZIP do `RetroX.app` para Apple Silicon e o SHA-256 anexados. A primeira versão automática será `1.0.0`. + +Para gerar o pacote localmente, execute `scripts/package-app.sh 1.2.3`. O arquivo vai para `dist/`, para a arquitetura do computador, com assinatura ad hoc. Cada execução do workflow também deixa um pacote de teste (versão `0.0.0`) em **Artifacts**. Não é preciso conta Apple: não há notarização, e o macOS pode pedir autorização para abrir o app. + +## Detalhes de implementação + +O RetroX é escrito em Swift 6 (concorrência estrita) com SwiftUI e AppKit, e inclui: + +* Núcleo (`RetroXCore`) sem interface, testável só com as Command Line Tools +* Emuladores como plugins, com detecção do app, das pastas de jogos, das capas e da BIOS +* Launch pelo LaunchServices com argumentos em array, sem shell, e retorno automático quando o emulador fecha +* Biblioteca não destrutiva: lê as pastas de jogos e nunca escreve nelas +* Metadados offline primeiro, com busca opcional na Wikipedia +* Navegação completa por teclado e controle (GameController), com glifos por tipo de controle +* 12 temas com variante clara e escura, entre eles o Rayo Paradise, com contraste conferido por testes + +## Estrutura do projeto + +* `Sources/RetroXCore/`: domínio, plugins de emulador, launch, biblioteca, perfis, metadados e temas +* `Sources/RetroXApp/`: o app em SwiftUI (telas, sistema de design, entrada por controle) e os recursos da marca +* `Sources/RXLaunchProbe/`: CLI de diagnóstico de launch +* `Sources/RXFakeEmulator/`: emulador falso usado nos testes de integração +* `Tests/RetroXCoreTests/`: testes unitários e de integração +* `Support/`: `Info.plist` e ícone do app +* `scripts/`: build, testes, empacotamento e geração da marca +* `docs/`: produto, ADRs, decisões, spikes e design + +## Documentação + +- [PRD](docs/PRD.md) — produto, escopo e critérios de aceite +- [Decisões de arquitetura](docs/adr/) — interface, plugins, lançamento e metadados +- [Decisões e pendências](docs/DECISOES.md) — escolhas e próximos passos +- [Estratégia de testes](docs/TESTING.md) — mocks, integração e emuladores +- [Spikes](docs/spikes/) — evidências com emuladores reais +- [Design](docs/design/brand-spec.md) e [kit da marca](docs/design/logo/README.md) — identidade e componentes +- [Origem](docs/origem/) — especificação original do projeto + +

+ Rayo, personagem da identidade visual do RetroX +
+ Conheça a Rayo no kit da marca. +

+ +## Licença + +Copyright © 2026 Otávio Luque ([yukaritake@proton.me](mailto:yukaritake@proton.me)). + +Este projeto é licenciado sob a GNU General Public License v3.0. Veja [LICENSE](LICENSE) para os detalhes. + +Mesmo com a licença GPL, fique à vontade para entrar em contato se quiser usar o RetroX num projeto proprietário. Estou aberto a combinar licenças alternativas caso a caso. -**Nota:** A documentação citada é referência técnica, não licença para copiar ativos ou código de terceiros. +PCSX2, Dolphin e os nomes de consoles citados pertencem aos seus respectivos donos. O RetroX não é afiliado a eles, e ilustrações, logos e telas de terceiros citados como inspiração não são usados no projeto. diff --git a/Sources/RXFakeEmulator/main.swift b/Sources/RXFakeEmulator/main.swift new file mode 100644 index 0000000..0c282df --- /dev/null +++ b/Sources/RXFakeEmulator/main.swift @@ -0,0 +1,23 @@ +import AppKit + +// Emulador falso para os testes de integração: grava o argv recebido em JSON ao lado do .app +// (`argv-.json`) e fica aberto alguns segundos, para os testes simularem "emulador já aberto". + +let outputURL = Bundle.main.bundleURL.deletingLastPathComponent().appending(path: "argv-\(getpid()).json") +do { + try JSONEncoder().encode(Array(CommandLine.arguments.dropFirst())).write(to: outputURL) +} catch { + FileHandle.standardError.write(Data("rx-fake-emulator: \(error)\n".utf8)) + exit(1) +} + +// Integração de processos no CI: termina sem acessar WindowServer/LaunchServices. +if let argument = CommandLine.arguments.first(where: { $0.hasPrefix("--rx-headless-exit=") }), + let status = Int32(argument.dropFirst("--rx-headless-exit=".count)) { + exit(status) +} + +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +DispatchQueue.main.asyncAfter(deadline: .now() + 4) { exit(0) } +app.run() diff --git a/Sources/RXLaunchProbe/main.swift b/Sources/RXLaunchProbe/main.swift new file mode 100644 index 0000000..f62a9f8 --- /dev/null +++ b/Sources/RXLaunchProbe/main.swift @@ -0,0 +1,130 @@ +import Foundation +import RetroXCore + +// Ferramenta de diagnóstico do spike de launch: usa o mesmo GameLauncher do app, +// mostra o plano validado e, sem --dry-run, abre o emulador. + +// Saída por linha mesmo redirecionada para arquivo: scripts leem o "pid:" enquanto o emulador roda. +setvbuf(stdout, nil, _IOLBF, 0) + +let usage = """ + uso: rx-launch-probe --app --rom [opções] + + --strategy workspace|process como criar o processo (padrão: o do adapter) + --dry-run só valida e mostra o argv, sem abrir nada + --new-instance abre outra instância mesmo com o emulador aberto + --fullscreen on|off força ou desliga a tela cheia (padrão: config do emulador) + --no-batch não passa -batch (o emulador não fecha ao parar o jogo) + --log grava stdout/stderr do emulador (só com --strategy process) + --wait espera o emulador fechar e informa como a sessão terminou + """ + +func fail(_ message: String, code: Int32 = 64) -> Never { + FileHandle.standardError.write(Data("\(message)\n".utf8)) + exit(code) +} + +var appPath: String? +var romPath: String? +var strategy: LaunchStrategy? +var dryRun = false +var newInstance = false +var waitSeconds: Int? +var logPath: String? +var pcsx2 = PCSX2Options() + +var input = CommandLine.arguments.dropFirst().makeIterator() +@MainActor func value(for flag: String) -> String { + guard let next = input.next() else { fail("\(flag) precisa de um valor.\n\n\(usage)") } + return next +} +while let flag = input.next() { + switch flag { + case "--app": appPath = value(for: flag) + case "--rom": romPath = value(for: flag) + case "--strategy": + guard let parsed = LaunchStrategy(rawValue: value(for: flag)) else { fail("Estratégia inválida.\n\n\(usage)") } + strategy = parsed + case "--dry-run": dryRun = true + case "--new-instance": newInstance = true + case "--fullscreen": + switch value(for: flag) { + case "on": pcsx2.fullscreen = .on + case "off": pcsx2.fullscreen = .off + default: fail("--fullscreen aceita on ou off.") + } + case "--no-batch": pcsx2.batch = false + case "--log": logPath = value(for: flag) + case "--wait": + guard let seconds = Int(value(for: flag)), seconds > 0 else { fail("--wait precisa de segundos > 0.") } + waitSeconds = seconds + case "-h", "--help": print(usage); exit(0) + default: fail("Opção desconhecida: \(flag)\n\n\(usage)") + } +} +guard let appPath, let romPath else { fail(usage) } + +let profile = EmulatorProfile( + name: "PCSX2", + adapterID: .pcsx2, + appURL: URL(filePath: appPath), + options: .pcsx2(pcsx2), + strategy: strategy +) +let request = LaunchRequest( + romURL: URL(filePath: romPath), + systemID: .ps2, + profile: profile, + alreadyRunningPolicy: newInstance ? .launchNewInstance : .refuse +) +let launcher = GameLauncher(launchers: [ + .workspace: WorkspaceLauncher(), + .process: FoundationProcessLauncher(outputLogURL: logPath.map { URL(filePath: $0) }), +]) + +do { + let prepared = try launcher.prepare(request) + let app = prepared.app + let architectures = app.architectures.map(\.description).sorted().joined(separator: ", ") + print("emulador: \(app.bundleIdentifier ?? "?") \(app.version ?? "?") [\(architectures)]") + print("executável: \(prepared.plan.executableURL.path(percentEncoded: false))") + for warning in prepared.warnings { + print("aviso: \(warning.message)") + } + print("estratégia: \(prepared.plan.strategy.rawValue)") + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + print("argv: \(String(decoding: try! encoder.encode(prepared.plan.arguments), as: UTF8.self))") + if dryRun { + print("modo simulado: nada foi aberto.") + exit(0) + } + + let launched = try await launcher.launch(prepared) + print("pid: \(launched.pid)") + guard let waitSeconds else { exit(0) } + + let exit = await withTaskGroup(of: ProcessExit?.self) { group in + group.addTask { await launched.waitForExit() } + group.addTask { + try? await Task.sleep(for: .seconds(waitSeconds)) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first + } + guard let exit else { + print("o emulador continua aberto após \(waitSeconds) s.") + Foundation.exit(0) + } + let status = exit.status.map(String.init) ?? "n/d" + switch SessionOutcome.classify(launchedAt: launched.launchedAt, exit: exit) { + case .ended(let after): + print("término: sessão normal após \(after.formatted(.units(allowed: [.minutes, .seconds]))) (status \(status))") + case .exitedEarly(_, let after): + print("término: fechou cedo, após \(after.formatted(.units(allowed: [.seconds], fractionalPart: .show(length: 1)))) (status \(status))") + } +} catch { + fail("erro: \(error.message)\nações: \(error.recoveryActions.map(\.title).joined(separator: ", "))", code: 1) +} diff --git a/Sources/RetroXApp/App/AppModel.swift b/Sources/RetroXApp/App/AppModel.swift new file mode 100644 index 0000000..0bc1d25 --- /dev/null +++ b/Sources/RetroXApp/App/AppModel.swift @@ -0,0 +1,576 @@ +import AppKit +import Observation +import RetroXCore +import SwiftUI + +/// Raiz do estado do app: navegação, perfis, plugins, biblioteca, partida, ajustes e entrada. +@MainActor +@Observable +final class AppModel { + enum Screen: Hashable { + case profiles, home, library, emulators, settings + + /// Abas da barra superior, na ordem em que os gatilhos do controle passam por elas. + static let tabs: [Screen] = [.home, .library, .emulators, .settings] + + var title: String { + switch self { + case .profiles: String(localized: "Perfis") + case .home: String(localized: "Início") + case .library: String(localized: "Biblioteca") + case .emulators: String(localized: "Emuladores") + case .settings: String(localized: "Ajustes") + } + } + + var symbol: String { + switch self { + case .profiles: "person.2" + case .home: "house" + case .library: "square.grid.2x2" + case .emulators: "puzzlepiece.extension" + case .settings: "slider.horizontal.3" + } + } + } + + enum SplashPhase { + case intro, revealed, done + } + + /// Tudo o que, ao mudar, pede para redesenhar a árvore inteira com os tokens novos. + struct StyleIdentity: Hashable { + var theme = ThemeSelection() + var text = TextSettings() + var motion = MotionSettings() + } + + private(set) var screen: Screen = .profiles + /// +1 indo para uma aba à direita, -1 para a esquerda: define o lado do deslize. + private(set) var navigationDirection = 1 + private(set) var splash: SplashPhase = .done + var detailsGame: Game? + var editingProfile: ProfileDraft? + /// Na tela de perfis: índice do perfil em foco (o último é "Adicionar perfil"). + var profilePickerIndex = 0 + /// Mensagem de erro curta para ações pontuais (importar imagem, por exemplo). + var notice: String? + + // Ajustes e Emuladores navegáveis por controle e teclado. + var settingsSection: SettingsSection = .appearance + /// `nil`: o foco está na lista de seções; senão, na linha de opções. + var settingsRow: Int? + var emulatorsFocus = 0 + /// O anel de foco de listas aparece depois do primeiro comando de teclado ou controle. + private(set) var showsListFocus = false + + private(set) var styleIdentity = StyleIdentity() + /// Rodando só para capturar a janela (`-RetroXSnapshot`): ignora entradas e nunca abre jogo, para uma + /// tecla digitada em outro app não disparar nada. + let isReviewMode = UserDefaults.standard.string(forKey: "RetroXSnapshot") != nil + private(set) var appearance: AppearancePreference = .system + + let profiles = ProfilesModel() + let plugins = PluginsModel() + let library = LibraryModel() + let browser: LibraryBrowser + let session = SessionModel() + let covers = CoverImageStore() + let input = InputCoordinator() + let metadata = MetadataSearchModel() + + init() { + browser = LibraryBrowser(library: library) + profilePickerIndex = profiles.profiles.firstIndex { $0.id == profiles.lastUsed?.id } ?? 0 + applyStyle(of: profiles.lastUsed) + let isSnapshot = UserDefaults.standard.string(forKey: "RetroXSnapshot") != nil + let forcesSplash = UserDefaults.standard.bool(forKey: "RetroXSplash") + if forcesSplash || (!isSnapshot && preferences.startupAnimation && Motion.settings.level != .off) { + splash = .intro + } + // Para revisão visual sem clicar: `RetroX -RetroXInitialScreen home|library|emulators|settings`. + if let initial = UserDefaults.standard.string(forKey: "RetroXInitialScreen"), initial != "profiles", + let profile = profiles.lastUsed { + enter(profile) + screen = switch initial { + case "library": .library + case "emulators": .emulators + case "settings": .settings + default: .home + } + } + input.onAction = { [weak self] action in self?.handle(action) } + session.onLaunched = { [weak self] game, date in self?.library.recordPlay(game, at: date) } + } + + func start() async { + await plugins.discoverAll() + await reloadLibrary() + } + + func reloadLibrary() async { + await library.reload(from: plugins.readyPlugins) + } + + /// Abertura: a Rayo e o nome entram, ficam um instante e a tela de perfis aparece por baixo. + func runSplash() async { + guard splash == .intro else { return } + try? await Task.sleep(for: .milliseconds(60)) + withAnimation(Motion.screen) { splash = .revealed } + try? await Task.sleep(for: .milliseconds(Int(1300 * Motion.settings.factor))) + withAnimation(Motion.screen) { splash = .done } + } + + // MARK: Navegação + + func navigate(to target: Screen) { + guard target != screen else { return } + let from = Screen.tabs.firstIndex(of: screen) ?? -1 + let to = Screen.tabs.firstIndex(of: target) ?? -1 + navigationDirection = to >= from ? 1 : -1 + showsListFocus = false + if target == .settings { settingsRow = nil } + withAnimation(Motion.screen) { screen = target } + } + + func cycleTab(by offset: Int) { + guard let index = Screen.tabs.firstIndex(of: screen) else { return } + let count = Screen.tabs.count + navigate(to: Screen.tabs[(index + offset + count) % count]) + } + + // MARK: Perfis + + func enter(_ profile: Profile) { + profiles.select(profile) + library.use(profile, storage: profiles.storage) + browser.clearFilters() + applyStyle(of: profile) + navigate(to: .home) + } + + func showProfiles() { + detailsGame = nil + profilePickerIndex = profiles.profiles.firstIndex { $0.id == profiles.current?.id } ?? 0 + navigate(to: .profiles) + } + + func newProfile() { + withAnimation(Motion.screen) { editingProfile = ProfileDraft(profile: Profile(name: ""), isNew: true) } + } + + func edit(_ profile: Profile) { + withAnimation(Motion.screen) { editingProfile = ProfileDraft(profile: profile, isNew: false) } + } + + func closeEditor() { + withAnimation(Motion.screen) { editingProfile = nil } + } + + func save(_ draft: ProfileDraft) { + var profile = draft.profile + profile.name = profile.name.trimmingCharacters(in: .whitespaces) + if profile.name.isEmpty { profile.name = String(localized: "Jogador \(profiles.profiles.count + (draft.isNew ? 1 : 0))") } + if draft.isNew { + let created = profiles.create(name: profile.name) + profile.id = created.id + } + profiles.update(profile) + if draft.removeAvatar { profiles.removeAvatar(for: profile) } + if let source = draft.avatarSource, let saved = profiles.profiles.first(where: { $0.id == profile.id }) { + do { + try profiles.setAvatar(for: saved, from: source) + } catch { + notice = String(localized: "Não foi possível usar essa imagem como foto.") + } + } + if profiles.current?.id == profile.id { applyStyle(of: profiles.current) } + if draft.isNew { profilePickerIndex = profiles.profiles.count - 1 } + closeEditor() + } + + func delete(_ draft: ProfileDraft) { + profiles.delete(draft.profile) + closeEditor() + profilePickerIndex = min(profilePickerIndex, profiles.profiles.count - 1) + if profiles.current == nil { showProfiles() } + } + + // MARK: Estilo do perfil + + var preferences: ProfilePreferences { + var preferences = (profiles.current ?? profiles.lastUsed)?.preferences ?? ProfilePreferences() + // Só para revisão visual, sem gravar no perfil: `-RetroXHomeStyle carousel|grid`. + if let style = UserDefaults.standard.string(forKey: "RetroXHomeStyle").flatMap(ProfilePreferences.HomeStyle.init(rawValue:)) { + preferences.homeStyle = style + } + return preferences + } + + /// Muda o tema do perfil atual, aplicando na hora. + func setTheme(_ theme: ThemeID? = nil, accent: Palette.Accent? = nil, appearance: AppearancePreference? = nil) { + guard var profile = profiles.current else { return } + if let theme { profile.theme = theme } + if let accent { profile.accent = accent } + if let appearance { profile.appearance = appearance } + profiles.update(profile) + applyStyle(of: profile) + } + + func updatePreferences(_ change: (inout ProfilePreferences) -> Void) { + guard var profile = profiles.current else { return } + change(&profile.preferences) + profiles.update(profile) + applyStyle(of: profile) + } + + private func applyStyle(of profile: Profile?) { + var selection = ThemeSelection(theme: profile?.theme ?? .retrox, accent: profile?.accent ?? .coral) + var appearance = profile?.appearance ?? .system + // Só para revisão visual, sem gravar no perfil: `-RetroXTheme gruvbox -RetroXAppearance light`. + let defaults = UserDefaults.standard + if let theme = defaults.string(forKey: "RetroXTheme").flatMap(ThemeID.init(rawValue:)) { selection.theme = theme } + if let forced = defaults.string(forKey: "RetroXAppearance").flatMap(AppearancePreference.init(rawValue:)) { appearance = forced } + let preferences = profile?.preferences ?? ProfilePreferences() + Theme.current = selection + Typography.settings = TextSettings(fontStyle: preferences.fontStyle, scale: preferences.textSize.scale) + Motion.settings = MotionSettings(level: preferences.animationLevel, factor: preferences.animationSpeed.durationFactor) + styleIdentity = StyleIdentity(theme: selection, text: Typography.settings, motion: Motion.settings) + self.appearance = appearance + } + + // MARK: Plugins + + func setPlugin(_ id: EmulatorPluginID, enabled: Bool) async { + await plugins.setEnabled(id, enabled) + await reloadLibrary() + } + + func chooseRoot(for id: EmulatorPluginID) async { + await plugins.chooseRoot(for: id) + await reloadLibrary() + } + + func rediscover(_ id: EmulatorPluginID) async { + await plugins.discover(id) + await reloadLibrary() + } + + /// Controles focáveis da tela de Emuladores, na ordem em que o controle passa por eles. + var emulatorControls: [FocusableControl] { + plugins.entries.flatMap { entry -> [FocusableControl] in + let id = entry.id + var controls = [FocusableControl(id: "\(id).toggle") { [weak self] in + Task { await self?.setPlugin(id, enabled: !entry.config.isEnabled) } + }] + if entry.config.isEnabled { + controls.append(FocusableControl(id: "\(id).root") { [weak self] in Task { await self?.chooseRoot(for: id) } }) + if let root = entry.discovery?.root { + controls.append(FocusableControl(id: "\(id).rediscover") { [weak self] in Task { await self?.rediscover(id) } }) + controls.append(FocusableControl(id: "\(id).reveal") { NSWorkspace.shared.activateFileViewerSelecting([root]) }) + } + } + return controls + } + } + + func isFocused(emulatorControl id: String) -> Bool { + showsListFocus && emulatorControls.indices.contains(emulatorsFocus) && emulatorControls[emulatorsFocus].id == id + } + + // MARK: Jogos + + func play(_ game: Game) { + guard !isReviewMode else { return } + withAnimation(Motion.screen) { detailsGame = nil } + guard let entry = plugins.entry(game.pluginID) else { return } + Task { await session.play(game, plugin: entry.plugin, app: entry.discovery?.app) } + } + + func showDetails(_ game: Game?) { + metadata.cancel() + withAnimation(Motion.screen) { detailsGame = game } + } + + func plan(for game: Game) -> Result { + guard let entry = plugins.entry(game.pluginID), let app = entry.discovery?.app else { + return .failure(.emulatorNotConfigured(game.systemID)) + } + do { + return .success(try session.plan(for: game, plugin: entry.plugin, app: app)) + } catch { + return .failure(error) + } + } + + func chooseCustomCover(for game: Game) async { + let message = String(localized: "Escolha uma imagem para a capa de \(game.title). Uma cópia reduzida é guardada no RetroX.") + guard let source = await FilePanel.chooseImage(message: message) else { return } + do { + let cover = try library.setCustomCover(for: game, from: source) + covers.invalidate(cover) + if detailsGame != nil { detailsGame = library.game(withID: game.id) } + } catch { + notice = String(localized: "Não foi possível usar essa imagem como capa.") + } + } + + func restoreOriginalCover(for game: Game) { + if let cover = game.coverURL { covers.invalidate(cover) } + library.removeCustomCover(for: game) + detailsGame = library.game(withID: game.id) + } + + func perform(_ action: RecoveryAction, for game: Game) { + switch action { + case .chooseEmulator, .openSettings: + session.dismiss() + navigate(to: .emulators) + case .locateROM: + session.dismiss() + Task { await reloadLibrary() } + case .retry: + session.dismiss() + play(game) + case .bringEmulatorToFront: + session.bringEmulatorToFront() + case .showRosettaHelp: + break + } + } + + /// O jogo em foco na tela atual (para o fundo ambiente). + var ambientGame: Game? { + switch screen { + case .home: library.focusedGame + case .library: browser.focusedGame + default: nil + } + } + + // MARK: Entrada + + /// Um só roteador para teclado e controle: a mesma ação faz a mesma coisa em qualquer dispositivo. + func handle(_ action: InputAction) { + guard splash == .done, !isReviewMode else { return } + if editingProfile != nil { + if action == .back { closeEditor() } + return + } + if session.isPresenting { return handleSession(action) } + if let game = detailsGame { + switch action { + case .confirm: play(game) + case .back, .details: showDetails(nil) + case .favorite: library.toggleFavorite(game) + default: break + } + return + } + if screen != .profiles { + switch action { + case .previousTab: return cycleTab(by: -1) + case .nextTab: return cycleTab(by: 1) + case .menu: return navigate(to: screen == .settings ? .home : .settings) + case .options: return navigate(to: screen == .emulators ? .home : .emulators) + default: break + } + } + switch screen { + case .profiles: handleProfiles(action) + case .home: handleHome(action) + case .library: handleLibrary(action) + case .emulators: handleEmulators(action) + case .settings: handleSettings(action) + } + } + + private func handleProfiles(_ action: InputAction) { + let count = profiles.profiles.count + withAnimation(Motion.focus) { + switch action { + case .left: profilePickerIndex = max(profilePickerIndex - 1, 0) + case .right: profilePickerIndex = min(profilePickerIndex + 1, count) + case .confirm: + if profilePickerIndex < count { enter(profiles.profiles[profilePickerIndex]) } else { newProfile() } + case .details: + if profilePickerIndex < count { edit(profiles.profiles[profilePickerIndex]) } + default: break + } + } + } + + private func handleHome(_ action: InputAction) { + let isGrid = preferences.homeStyle == .grid + switch action { + case .left: library.moveFocus(by: -1) + case .right: library.moveFocus(by: 1) + case .up: isGrid ? library.moveFocus(by: -library.homeColumns) : library.cycleShelf(by: -1) + case .down: isGrid ? library.moveFocus(by: library.homeColumns) : library.cycleShelf(by: 1) + case .previousShelf: library.cycleShelf(by: -1) + case .nextShelf: library.cycleShelf(by: 1) + case .confirm: library.focusedGame.map(play) + case .details: showDetails(library.focusedGame) + case .favorite: library.focusedGame.map(library.toggleFavorite) + default: break + } + } + + private func handleLibrary(_ action: InputAction) { + switch action { + case .left: browser.moveFocus(by: -1) + case .right: browser.moveFocus(by: 1) + case .up: browser.moveFocus(by: -browser.columns) + case .down: browser.moveFocus(by: browser.columns) + case .previousShelf: browser.cycleSystem(by: -1) + case .nextShelf: browser.cycleSystem(by: 1) + case .confirm: browser.focusedGame.map(play) + case .details: showDetails(browser.focusedGame) + case .favorite: browser.focusedGame.map(library.toggleFavorite) + case .back: + if browser.isFiltering { browser.clearFilters() } else { navigate(to: .home) } + default: break + } + } + + private func handleEmulators(_ action: InputAction) { + let controls = emulatorControls + showsListFocus = true + switch action { + case .up, .left: emulatorsFocus = max(emulatorsFocus - 1, 0) + case .down, .right: emulatorsFocus = min(emulatorsFocus + 1, max(controls.count - 1, 0)) + case .confirm: if controls.indices.contains(emulatorsFocus) { controls[emulatorsFocus].run() } + case .back: navigate(to: .home) + default: break + } + } + + private func handleSettings(_ action: InputAction) { + showsListFocus = true + let sections = SettingsSection.allCases + guard let row = settingsRow else { + let index = sections.firstIndex(of: settingsSection) ?? 0 + switch action { + case .up: settingsSection = sections[max(index - 1, 0)] + case .down: settingsSection = sections[min(index + 1, sections.count - 1)] + case .right, .confirm: if !settingsRows.isEmpty { settingsRow = 0 } + case .back: navigate(to: .home) + default: break + } + return + } + let rows = settingsRows + guard rows.indices.contains(row) else { settingsRow = rows.isEmpty ? nil : rows.count - 1; return } + switch action { + case .up: settingsRow = max(row - 1, 0) + case .down: settingsRow = min(row + 1, rows.count - 1) + case .left: rows[row].step(by: -1) + case .right: rows[row].step(by: 1) + case .confirm: rows[row].activate() + case .back: settingsRow = nil + default: break + } + } + + private func handleSession(_ action: InputAction) { + switch session.phase { + case .running: + if action == .confirm { session.bringEmulatorToFront() } + case .failed(let game, let error): + if action == .confirm, let first = error.recoveryActions.first { perform(first, for: game) } + if action == .back { session.dismiss() } + case .ended: + if action == .back || action == .confirm { session.dismiss() } + case .idle, .launching: + break + } + } +} + +/// Um controle que teclado e controle de jogo alcançam com as setas. +struct FocusableControl: Identifiable { + let id: String + let run: () -> Void +} + +/// Cópia de um perfil sendo editado; só vale ao salvar. +@MainActor +@Observable +final class ProfileDraft: Identifiable { + let id = UUID() + var profile: Profile + let isNew: Bool + /// Foto nova escolhida (copiada ao salvar). + var avatarSource: URL? + var removeAvatar = false + + init(profile: Profile, isNew: Bool) { + self.profile = profile + self.isNew = isNew + } +} + +/// Busca de informações de um jogo numa fonte pública, com escolha do usuário (ADR-0014). +@MainActor +@Observable +final class MetadataSearchModel { + enum Phase: Equatable { + case idle + case searching + case results([MetadataCandidate]) + case loading(MetadataCandidate) + case failed(String) + } + + private(set) var phase: Phase = .idle + private(set) var gameID: Game.ID? + var query = "" + + @ObservationIgnored private let provider = WikipediaMetadataProvider() + + var isActive: Bool { gameID != nil } + + func start(for game: Game) { + gameID = game.id + query = game.searchTitle + Task { await search() } + } + + func search() async { + guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { return } + phase = .searching + do { + phase = .results(try await provider.search(query)) + } catch { + phase = .failed(Self.message(for: error)) + } + } + + func choose(_ candidate: MetadataCandidate, for game: Game, library: LibraryModel) async { + phase = .loading(candidate) + do { + library.setMetadata(try await provider.metadata(for: candidate), for: game) + cancel() + } catch { + phase = .failed(Self.message(for: error)) + } + } + + func cancel() { + phase = .idle + gameID = nil + } + + private static func message(for error: Error) -> String { + switch error as? MetadataError { + case .http(let status) where status >= 500: + String(localized: "A Wikipedia não respondeu agora (erro \(status)). Tente de novo em alguns minutos.") + case .http(let status): + String(localized: "A Wikipedia recusou a busca (erro \(status)).") + case .network: + String(localized: "Sem conexão com a internet. O RetroX continua funcionando; só a busca precisa de rede.") + default: + String(localized: "A resposta da Wikipedia veio num formato inesperado.") + } + } +} diff --git a/Sources/RetroXApp/App/DebugSnapshot.swift b/Sources/RetroXApp/App/DebugSnapshot.swift new file mode 100644 index 0000000..e9e4044 --- /dev/null +++ b/Sources/RetroXApp/App/DebugSnapshot.swift @@ -0,0 +1,19 @@ +import AppKit + +/// Revisão visual sem permissão de Gravação de Tela: o app grava a própria janela em PNG. +/// `RetroX -RetroXSnapshot /caminho/tela.png [-RetroXInitialScreen emulators] [-RetroXSnapshotDelay 0.8]` +@MainActor +enum DebugSnapshot { + static func captureIfRequested() async { + guard let path = UserDefaults.standard.string(forKey: "RetroXSnapshot") else { return } + let delay = UserDefaults.standard.object(forKey: "RetroXSnapshotDelay") == nil + ? 3 : UserDefaults.standard.double(forKey: "RetroXSnapshotDelay") + try? await Task.sleep(for: .seconds(delay)) + guard let window = NSApp.windows.first(where: { $0.isVisible && $0.contentView != nil }), + let view = window.contentView, + let representation = view.bitmapImageRepForCachingDisplay(in: view.bounds) + else { return } + view.cacheDisplay(in: view.bounds, to: representation) + try? representation.representation(using: .png, properties: [:])?.write(to: URL(filePath: path)) + } +} diff --git a/Sources/RetroXApp/App/FilePanel.swift b/Sources/RetroXApp/App/FilePanel.swift new file mode 100644 index 0000000..ebbcaf8 --- /dev/null +++ b/Sources/RetroXApp/App/FilePanel.swift @@ -0,0 +1,42 @@ +import AppKit +import UniformTypeIdentifiers + +/// Seletor nativo de pasta. Usa `NSOpenPanel` diretamente: o `fileImporter` do SwiftUI fechava o seletor +/// antes de entregar a escolha, e a escolha se perdia. +@MainActor +enum FilePanel { + static func chooseFolder(message: String, startingAt directory: URL?) async -> URL? { + let panel = NSOpenPanel() + panel.message = message + panel.prompt = String(localized: "Escolher") + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.canCreateDirectories = false + panel.directoryURL = directory + let response = await present(panel) + return response == .OK ? panel.url : nil + } + + static func chooseImage(message: String) async -> URL? { + let panel = NSOpenPanel() + panel.message = message + panel.prompt = String(localized: "Usar imagem") + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.image] + let response = await present(panel) + return response == .OK ? panel.url : nil + } + + private static func present(_ panel: NSOpenPanel) async -> NSApplication.ModalResponse { + await withCheckedContinuation { continuation in + if let window = NSApp.keyWindow { + panel.beginSheetModal(for: window) { continuation.resume(returning: $0) } + } else { + panel.begin { continuation.resume(returning: $0) } + } + } + } +} diff --git a/Sources/RetroXApp/App/LibraryBrowser.swift b/Sources/RetroXApp/App/LibraryBrowser.swift new file mode 100644 index 0000000..f8dd003 --- /dev/null +++ b/Sources/RetroXApp/App/LibraryBrowser.swift @@ -0,0 +1,155 @@ +import Foundation +import Observation +import RetroXCore + +/// Estado da grade da biblioteca: busca, filtros, ordem, forma de exibir e jogo em foco. +@MainActor +@Observable +final class LibraryBrowser { + enum Layout: String, CaseIterable, Identifiable { + case compact, covers, largeCovers, titledCovers, list + + var id: Self { self } + + var title: String { + switch self { + case .compact: String(localized: "Capas pequenas") + case .covers: String(localized: "Capas") + case .largeCovers: String(localized: "Capas grandes") + case .titledCovers: String(localized: "Capas e nomes") + case .list: String(localized: "Lista") + } + } + + var symbol: String { + switch self { + case .compact: "square.grid.4x3.fill" + case .covers: "square.grid.3x3" + case .largeCovers: "square.grid.2x2" + case .titledCovers: "rectangle.grid.2x2" + case .list: "list.bullet" + } + } + + /// Largura da capa em cada forma de exibir (a lista usa uma miniatura). + var tileWidth: CGFloat { + switch self { + case .compact: 96 + case .covers: 128 + case .largeCovers: 184 + case .titledCovers: 148 + case .list: 40 + } + } + } + + enum Sort: String, CaseIterable, Identifiable { + case title, recent, size, system + + var id: Self { self } + + var title: String { + switch self { + case .title: String(localized: "Nome") + case .recent: String(localized: "Jogados recentemente") + case .size: String(localized: "Tamanho") + case .system: String(localized: "Console") + } + } + } + + var searchText = "" + var system: GameSystemID? + var onlyFavorites = false + private(set) var layout: Layout + private(set) var sort: Sort + /// Quantas colunas cabem na grade (informado pela view), para mover o foco para cima e para baixo. + var columns = 1 + private(set) var focusedGameID: Game.ID? + /// Jogo sob o ponteiro do mouse, para o realce de hover. + var hoveredGameID: Game.ID? + + @ObservationIgnored private let library: LibraryModel + @ObservationIgnored private let defaults: UserDefaults + + init(library: LibraryModel, defaults: UserDefaults = .standard) { + self.library = library + self.defaults = defaults + layout = defaults.string(forKey: "library.layout").flatMap(Layout.init(rawValue:)) ?? .titledCovers + sort = defaults.string(forKey: "library.sort").flatMap(Sort.init(rawValue:)) ?? .title + } + + func setLayout(_ layout: Layout) { + self.layout = layout + defaults.set(layout.rawValue, forKey: "library.layout") + } + + func setSort(_ sort: Sort) { + self.sort = sort + defaults.set(sort.rawValue, forKey: "library.sort") + } + + var isFiltering: Bool { + !searchText.isEmpty || system != nil || onlyFavorites + } + + func clearFilters() { + searchText = "" + system = nil + onlyFavorites = false + } + + /// Jogos por console, para o filtro: todos os sistemas do catálogo, com a contagem (zero fica desativado). + var systemCounts: [(GameSystem, Int)] { + let counts = Dictionary(grouping: library.games, by: \.systemID).mapValues(\.count) + return SystemCatalog.all.map { ($0, counts[$0.id] ?? 0) } + } + + var results: [Game] { + let query = TitleParser.searchKey(searchText.trimmingCharacters(in: .whitespaces)) + let filtered = library.games.filter { game in + (system == nil || game.systemID == system) + && (!onlyFavorites || library.isFavorite(game)) + && (query.isEmpty || game.searchKey.contains(query) + || game.info.map { TitleParser.searchKey($0.name).contains(query) } == true + || game.serial.map { TitleParser.searchKey($0).contains(query) } == true) + } + switch sort { + case .title: + return filtered + case .recent: + return filtered.sorted { + (library.history[$0.id]?.lastPlayedAt ?? .distantPast) > (library.history[$1.id]?.lastPlayedAt ?? .distantPast) + } + case .size: + return filtered.sorted { $0.fileSize > $1.fileSize } + case .system: + let order = Dictionary(uniqueKeysWithValues: SystemCatalog.all.enumerated().map { ($1.id, $0) }) + return filtered.sorted { (order[$0.systemID] ?? .max, $0.title) < (order[$1.systemID] ?? .max, $1.title) } + } + } + + var focusedGame: Game? { + let all = results + return all.first { $0.id == focusedGameID } ?? all.first + } + + func focus(_ game: Game) { + focusedGameID = game.id + } + + /// Esquerda/direita andam um; cima/baixo andam uma linha inteira da grade. + func moveFocus(by offset: Int) { + let all = results + guard !all.isEmpty else { return } + let index = all.firstIndex { $0.id == focusedGame?.id } ?? 0 + focusedGameID = all[min(max(index + offset, 0), all.count - 1)].id + } + + /// LB/RB passam pelos consoles que têm jogos (e por "Todos"). + func cycleSystem(by offset: Int) { + let options: [GameSystemID?] = [nil] + systemCounts.filter { $0.1 > 0 }.map { Optional($0.0.id) } + let index = options.firstIndex(of: system) ?? 0 + system = options[(index + offset + options.count) % options.count] + } +} diff --git a/Sources/RetroXApp/App/LibraryModel.swift b/Sources/RetroXApp/App/LibraryModel.swift new file mode 100644 index 0000000..69376a6 --- /dev/null +++ b/Sources/RetroXApp/App/LibraryModel.swift @@ -0,0 +1,228 @@ +import Foundation +import Observation +import RetroXCore + +/// Os jogos dos plugins prontos, com o histórico do perfil atual e as personalizações (capa, informações). +@MainActor +@Observable +final class LibraryModel { + enum Shelf: Hashable, Identifiable { + case recent, all, favorites + case system(GameSystemID) + + var id: Self { self } + + var title: String { + switch self { + case .recent: String(localized: "Recentes") + case .all: String(localized: "Todos") + case .favorites: String(localized: "Favoritos") + case .system(let id): SystemCatalog.system(id)?.shortName ?? id.rawValue + } + } + } + + /// Jogos já com a capa escolhida pelo usuário, quando houver. + private(set) var games: [Game] = [] + private(set) var history: [String: GameHistory] = [:] + private(set) var customizations: [String: GameCustomization] + private(set) var isScanning = false + private(set) var hasLoaded = false + private(set) var shelf: Shelf = .all + private(set) var focusedGameID: Game.ID? + /// Colunas da grade da tela inicial (estilo Grade), para mover o foco para cima e para baixo. + var homeColumns = 1 + + @ObservationIgnored private var scanned: [Game] = [] + @ObservationIgnored private var startShelf: ProfilePreferences.StartShelf = .automatic + @ObservationIgnored private var historyStore: JSONFileStore<[String: GameHistory]>? + @ObservationIgnored let artwork: ArtworkStorage + + init(artwork: ArtworkStorage = ArtworkStorage()) { + self.artwork = artwork + customizations = artwork.customizations.load() ?? [:] + } + + // MARK: Perfil + + func use(_ profile: Profile, storage: ProfileStorage) { + historyStore = storage.history(for: profile.id) + history = historyStore?.load() ?? [:] + startShelf = profile.preferences.startShelf + shelf = initialShelf + focusedGameID = visibleGames.first?.id + } + + /// A prateleira escolhida nas preferências; Recentes só se houver algo jogado. + private var initialShelf: Shelf { + let hasRecent = shelves.contains(.recent) + switch startShelf { + case .automatic, .recent: return hasRecent ? .recent : .all + case .all: return .all + case .favorites: return .favorites + } + } + + // MARK: Prateleiras (tela inicial) + + var shelves: [Shelf] { + var result: [Shelf] = [] + if games.contains(where: { history[$0.id]?.lastPlayedAt != nil }) { result.append(.recent) } + result += [.all, .favorites] + let systems = Set(games.map(\.systemID)) + if systems.count > 1 { + result += SystemCatalog.all.map(\.id).filter(systems.contains).map(Shelf.system) + } + return result + } + + var visibleGames: [Game] { + switch shelf { + case .all: games + case .favorites: games.filter { isFavorite($0) } + case .recent: recentGames + case .system(let id): games.filter { $0.systemID == id } + } + } + + var recentGames: [Game] { + games + .filter { history[$0.id]?.lastPlayedAt != nil } + .sorted { (history[$0.id]?.lastPlayedAt ?? .distantPast) > (history[$1.id]?.lastPlayedAt ?? .distantPast) } + } + + var focusedIndex: Int? { + focusedIndex(in: visibleGames) + } + + var focusedGame: Game? { + let visible = visibleGames + return focusedIndex(in: visible).map { visible[$0] } + } + + private func focusedIndex(in visible: [Game]) -> Int? { + visible.firstIndex { $0.id == focusedGameID } ?? (visible.isEmpty ? nil : 0) + } + + func focus(_ game: Game) { + focusedGameID = game.id + } + + func moveFocus(by offset: Int) { + let visible = visibleGames + guard let index = focusedIndex(in: visible), !visible.isEmpty else { return } + focusedGameID = visible[min(max(index + offset, 0), visible.count - 1)].id + } + + func select(_ shelf: Shelf) { + guard shelf != self.shelf else { return } + let previous = focusedGameID + self.shelf = shelf + // Mantém o mesmo jogo em foco se ele também está na nova prateleira. + focusedGameID = visibleGames.contains { $0.id == previous } ? previous : visibleGames.first?.id + } + + func cycleShelf(by offset: Int) { + let all = shelves + guard let index = all.firstIndex(of: shelf) else { return select(.all) } + select(all[(index + offset + all.count) % all.count]) + } + + // MARK: Jogos + + func game(withID id: Game.ID) -> Game? { + games.first { $0.id == id } + } + + func gameCount(for plugin: EmulatorPluginID) -> Int { + games.filter { $0.pluginID == plugin }.count + } + + func reload(from ready: [(any EmulatorPlugin, PluginDiscovery)]) async { + isScanning = true + scanned = await Task.detached(priority: .userInitiated) { + ready.flatMap { plugin, discovery in LibraryBuilder.games(for: plugin, discovery: discovery) } + }.value + decorate() + let isFirstLoad = !hasLoaded + hasLoaded = true + isScanning = false + if isFirstLoad { + shelf = initialShelf + } else if !shelves.contains(shelf) { + shelf = .all + } + if focusedGame?.id != focusedGameID { + focusedGameID = visibleGames.first?.id + } + } + + // MARK: Histórico do perfil + + func history(for game: Game) -> GameHistory { + history[game.id] ?? GameHistory() + } + + func isFavorite(_ game: Game) -> Bool { + history[game.id]?.isFavorite == true + } + + func toggleFavorite(_ game: Game) { + edit(game) { $0.isFavorite.toggle() } + } + + func recordPlay(_ game: Game, at date: Date) { + edit(game) { + $0.lastPlayedAt = date + $0.playCount += 1 + } + } + + private func edit(_ game: Game, _ change: (inout GameHistory) -> Void) { + var entry = history[game.id] ?? GameHistory() + change(&entry) + history[game.id] = entry + try? historyStore?.save(history) + } + + // MARK: Personalização (compartilhada entre perfis) + + func customization(for game: Game) -> GameCustomization? { + customizations[game.id] + } + + func hasCustomCover(_ game: Game) -> Bool { + customizations[game.id]?.coverFile != nil + } + + /// Copia a imagem escolhida como capa e devolve o endereço dela. + @discardableResult + func setCustomCover(for game: Game, from source: URL) throws -> URL { + let name = try artwork.importCover(from: source, for: game.id) + customize(game) { $0.coverFile = name } + return artwork.artworkDirectory.appending(path: name) + } + + func removeCustomCover(for game: Game) { + if let url = artwork.coverURL(for: customizations[game.id]) { try? FileManager.default.removeItem(at: url) } + customize(game) { $0.coverFile = nil } + } + + func setMetadata(_ metadata: GameMetadata?, for game: Game) { + customize(game) { $0.metadata = metadata } + } + + private func customize(_ game: Game, _ change: (inout GameCustomization) -> Void) { + var entry = customizations[game.id] ?? GameCustomization() + change(&entry) + customizations[game.id] = entry == GameCustomization() ? nil : entry + try? artwork.customizations.save(customizations) + decorate() + } + + private func decorate() { + games = scanned.map { game in + artwork.coverURL(for: customizations[game.id]).map(game.withCover) ?? game + } + } +} diff --git a/Sources/RetroXApp/App/PluginsModel.swift b/Sources/RetroXApp/App/PluginsModel.swift new file mode 100644 index 0000000..4fbb72d --- /dev/null +++ b/Sources/RetroXApp/App/PluginsModel.swift @@ -0,0 +1,99 @@ +import AppKit +import Observation +import RetroXCore + +/// Emuladores (plugins) que o usuário ativou, a pasta raiz de cada um e o que foi encontrado nela. +@MainActor +@Observable +final class PluginsModel { + struct Entry: Identifiable { + let plugin: any EmulatorPlugin + var config: PluginConfig + var discovery: PluginDiscovery? + var isDiscovering = false + + var id: EmulatorPluginID { plugin.id } + var rootURL: URL? { config.rootPath.map { URL(filePath: $0, directoryHint: .isDirectory) } } + var isReady: Bool { config.isEnabled && discovery?.isReady == true } + } + + private(set) var entries: [Entry] + /// Emuladores conhecidos ainda sem plugin, com o app encontrado neste Mac (se houver). + private(set) var otherEmulators: [(PluginCatalog.KnownEmulator, URL?)] = [] + @ObservationIgnored private let store: JSONFileStore<[String: PluginConfig]> + + init( + catalog: [any EmulatorPlugin] = PluginCatalog.available, + store: JSONFileStore<[String: PluginConfig]> = JSONFileStore(url: RetroXDirectories.applicationSupport.appending(path: "plugins.json")) + ) { + self.store = store + let saved = store.load() ?? [:] + entries = catalog.map { Entry(plugin: $0, config: saved[$0.id.rawValue] ?? PluginConfig()) } + } + + var readyPlugins: [(any EmulatorPlugin, PluginDiscovery)] { + entries.compactMap { entry in + guard entry.isReady, let discovery = entry.discovery else { return nil } + return (entry.plugin, discovery) + } + } + + var hasEnabledPlugin: Bool { entries.contains { $0.config.isEnabled } } + + func entry(_ id: EmulatorPluginID) -> Entry? { + entries.first { $0.id == id } + } + + /// Ativar sem pasta raiz abre o seletor; cancelar o seletor deixa o plugin desativado. + func setEnabled(_ id: EmulatorPluginID, _ isEnabled: Bool) async { + guard let entry = entry(id) else { return } + if isEnabled, entry.config.rootPath == nil { + await chooseRoot(for: id) + return + } + update(id) { $0.config.isEnabled = isEnabled } + if isEnabled, entry.discovery == nil { await discover(id) } + } + + func chooseRoot(for id: EmulatorPluginID) async { + guard let entry = entry(id) else { return } + let message = String(localized: "Escolha a pasta do \(entry.plugin.displayName): a que tem o app do emulador e as pastas de jogos.") + guard let url = await FilePanel.chooseFolder(message: message, startingAt: entry.rootURL) else { return } + update(id) { + $0.config.rootPath = url.path(percentEncoded: false) + $0.config.isEnabled = true + $0.discovery = nil + } + await discover(id) + } + + func discover(_ id: EmulatorPluginID) async { + guard let entry = entry(id), let root = entry.rootURL else { return } + update(id) { $0.isDiscovering = true } + let plugin = entry.plugin + let discovery = await Task.detached(priority: .userInitiated) { plugin.discover(root: root) }.value + update(id) { + $0.discovery = discovery + $0.isDiscovering = false + } + } + + func discoverAll() async { + otherEmulators = PluginCatalog.known.compactMap { emulator in + let app = emulator.bundleIdentifiers.lazy.compactMap { NSWorkspace.shared.urlsForApplications(withBundleIdentifier: $0).first }.first + return emulator.isPlanned || app != nil ? (emulator, app) : nil + } + for entry in entries where entry.config.isEnabled { + await discover(entry.id) + } + } + + private func update(_ id: EmulatorPluginID, _ change: (inout Entry) -> Void) { + guard let index = entries.firstIndex(where: { $0.id == id }) else { return } + let previous = entries[index].config + change(&entries[index]) + if entries[index].config != previous { + try? store.save(Dictionary(uniqueKeysWithValues: entries.map { ($0.id.rawValue, $0.config) })) + } + } +} diff --git a/Sources/RetroXApp/App/ProfilesModel.swift b/Sources/RetroXApp/App/ProfilesModel.swift new file mode 100644 index 0000000..14c96b9 --- /dev/null +++ b/Sources/RetroXApp/App/ProfilesModel.swift @@ -0,0 +1,102 @@ +import AppKit +import Observation +import RetroXCore + +/// Perfis de jogador: quem está jogando, a foto, o tema e o histórico de cada um. +@MainActor +@Observable +final class ProfilesModel { + private(set) var profiles: [Profile] + private(set) var current: Profile? + /// Muda quando uma foto é trocada, para as views recarregarem a imagem do mesmo caminho. + private(set) var avatarRevision = 0 + + @ObservationIgnored let storage: ProfileStorage + @ObservationIgnored private var lastUsedID: UUID? + + init(storage: ProfileStorage = ProfileStorage()) { + self.storage = storage + let state = storage.state.load() ?? ProfilesState() + profiles = state.profiles + lastUsedID = state.lastUsedID + if profiles.isEmpty { createFirstProfile() } + } + + var lastUsed: Profile? { + profiles.first { $0.id == lastUsedID } ?? profiles.first + } + + func avatarURL(for profile: Profile) -> URL? { + storage.avatarURL(for: profile) + } + + func select(_ profile: Profile) { + current = profile + lastUsedID = profile.id + save() + } + + @discardableResult + func create(name: String) -> Profile { + let profile = Profile(name: name.isEmpty ? String(localized: "Jogador \(profiles.count + 1)") : name) + profiles.append(profile) + save() + return profile + } + + func update(_ profile: Profile) { + guard let index = profiles.firstIndex(where: { $0.id == profile.id }) else { return } + profiles[index] = profile + if current?.id == profile.id { current = profile } + save() + } + + /// Apaga o perfil e o histórico dele. Os jogos e as pastas dos emuladores não são tocados. + func delete(_ profile: Profile) { + guard profiles.count > 1 else { return } + profiles.removeAll { $0.id == profile.id } + try? FileManager.default.removeItem(at: storage.history(for: profile.id).url) + if let avatar = storage.avatarURL(for: profile) { try? FileManager.default.removeItem(at: avatar) } + if current?.id == profile.id { current = nil } + if lastUsedID == profile.id { lastUsedID = profiles.first?.id } + save() + } + + /// Copia a foto escolhida (reduzida) para dentro do RetroX. + func setAvatar(for profile: Profile, from source: URL) throws { + var updated = profile + let name = "\(profile.id.uuidString).png" + try ImageImporter.importPNG(from: source, to: storage.avatarsDirectory.appending(path: name), maxPixelSize: 512) + updated.avatarFile = name + update(updated) + avatarRevision += 1 + } + + func removeAvatar(for profile: Profile) { + guard let url = storage.avatarURL(for: profile) else { return } + try? FileManager.default.removeItem(at: url) + var updated = profile + updated.avatarFile = nil + update(updated) + avatarRevision += 1 + } + + /// Primeiro uso dos perfis: aproveita o tema escolhido antes nos Ajustes e o histórico que já existia. + private func createFirstProfile() { + let defaults = UserDefaults.standard + let fullName = NSFullUserName().split(separator: " ").first.map(String.init) + var profile = Profile(name: fullName ?? String(localized: "Jogador 1")) + if let accent = defaults.string(forKey: "accent").flatMap(Palette.Accent.init(rawValue:)) { profile.accent = accent } + if let appearance = defaults.string(forKey: "appearance").flatMap(AppearancePreference.init(rawValue:)) { + profile.appearance = appearance + } + profiles = [profile] + lastUsedID = profile.id + try? storage.migrateLegacyHistory(into: profile.id) + save() + } + + private func save() { + try? storage.state.save(ProfilesState(profiles: profiles, lastUsedID: lastUsedID)) + } +} diff --git a/Sources/RetroXApp/App/SessionModel.swift b/Sources/RetroXApp/App/SessionModel.swift new file mode 100644 index 0000000..399381f --- /dev/null +++ b/Sources/RetroXApp/App/SessionModel.swift @@ -0,0 +1,105 @@ +import AppKit +import Observation +import RetroXCore + +/// Uma partida: abrir o emulador, acompanhar enquanto ele roda e voltar à frente quando ele fecha. +@MainActor +@Observable +final class SessionModel { + enum Phase: Equatable { + case idle + case launching(Game) + case running(Game, emulator: String) + case ended(Game, after: Duration) + case failed(Game, LaunchError) + } + + private(set) var phase: Phase = .idle + private(set) var warnings: [LaunchWarning] = [] + + @ObservationIgnored var onLaunched: ((Game, Date) -> Void)? + @ObservationIgnored private let launcher: GameLauncher + @ObservationIgnored private let runningApplications: any RunningApplicationsProviding + @ObservationIgnored private var runningBundleID: String? + @ObservationIgnored private var dismissTask: Task? + + init(launcher: GameLauncher = GameLauncher(), runningApplications: any RunningApplicationsProviding = WorkspaceRunningApplications()) { + self.launcher = launcher + self.runningApplications = runningApplications + } + + var isPresenting: Bool { phase != .idle } + + var isBusy: Bool { + switch phase { + case .launching, .running: true + default: false + } + } + + /// O plano que seria usado, sem abrir nada (aparece nos detalhes do jogo). + func plan(for game: Game, plugin: any EmulatorPlugin, app: AppBundleInfo) throws(LaunchError) -> PreparedLaunch { + try launcher.prepare(request(for: game, plugin: plugin, app: app)) + } + + func play(_ game: Game, plugin: any EmulatorPlugin, app: AppBundleInfo?) async { + guard !isBusy else { return } + dismissTask?.cancel() + guard let app else { + phase = .failed(game, .emulatorNotConfigured(game.systemID)) + return + } + phase = .launching(game) + do { + let prepared = try launcher.prepare(request(for: game, plugin: plugin, app: app)) + warnings = prepared.warnings + let launched = try await launcher.launch(prepared) + runningBundleID = launched.plan.bundleIdentifier + phase = .running(game, emulator: plugin.displayName) + onLaunched?(game, launched.launchedAt) + guard let exit = await launched.waitForExit() else { return } + switch SessionOutcome.classify(launchedAt: launched.launchedAt, exit: exit) { + case .ended(let after): + phase = .ended(game, after: after) + scheduleDismiss() + case .exitedEarly(let status, let after): + phase = .failed(game, .exitedEarly(status: status, after: after)) + } + NSApplication.shared.activate() + } catch { + phase = .failed(game, error) + } + } + + func bringEmulatorToFront() { + let bundleID: String? = switch phase { + case .failed(_, .emulatorAlreadyRunning(let id)): id + default: runningBundleID + } + if let bundleID { runningApplications.activate(bundleIdentifier: bundleID) } + } + + func dismiss() { + guard !isBusy else { return } + dismissTask?.cancel() + phase = .idle + } + + private func scheduleDismiss() { + dismissTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { return } + self?.dismiss() + } + } + + private func request(for game: Game, plugin: any EmulatorPlugin, app: AppBundleInfo) -> LaunchRequest { + let profile = EmulatorProfile( + name: plugin.displayName, + adapterID: plugin.adapter.id, + appURL: app.bundleURL, + options: plugin.adapter.defaultOptions + ) + return LaunchRequest(romURL: game.romURL, systemID: game.systemID, profile: profile) + } +} diff --git a/Sources/RetroXApp/DesignSystem/Atmosphere.swift b/Sources/RetroXApp/DesignSystem/Atmosphere.swift new file mode 100644 index 0000000..b79da3b --- /dev/null +++ b/Sources/RetroXApp/DesignSystem/Atmosphere.swift @@ -0,0 +1,128 @@ +import AppKit +import RetroXCore +import SwiftUI + +/// Abertura do app: um gesto só. A Rayo aparece e o nome se revela a partir dela, na assinatura horizontal +/// do kit (a mesma da barra superior); depois tudo esmaece revelando a tela de perfis. Com Reduzir +/// Movimento, os dois só esmaecem. +struct SplashView: View { + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private static let wordSize: CGFloat = 40 + + var body: some View { + let shown = model.splash == .revealed + let moves = Motion.allowsMovement(reduceMotion: reduceMotion) + let metrics = Brand.Metrics(size: Self.wordSize) + let f = Motion.settings.factor + ZStack { + Theme.background + .ignoresSafeArea() + HStack(alignment: .center, spacing: 0.75 * metrics.capHeight - metrics.leftBearing) { + BrandMark(size: 2 * metrics.capHeight) + .scaleEffect(shown || !moves ? 1 : 0.9) + .opacity(shown ? 1 : 0) + .animation(moves ? .smooth(duration: 0.5 * f) : Motion.fade, value: shown) + BrandWordmark(size: Self.wordSize) + .alignmentGuide(VerticalAlignment.center) { $0[.firstTextBaseline] - metrics.capHeight / 2 } + .opacity(shown ? 1 : 0) + // O nome se escreve a partir do tile: a máscara abre da esquerda para a direita. + .mask(alignment: .leading) { + Rectangle().scaleEffect(x: shown || !moves ? 1 : 0, anchor: .leading) + } + .animation((moves ? Animation.smooth(duration: 0.6 * f) : .easeOut(duration: 0.3)).delay(0.25 * f), value: shown) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("RetroX") + } +} + +/// Fundo ambiente: a capa do jogo em foco, bem desfocada, atrás da tela. Troca devagar. +/// Ocupa exatamente o tamanho da janela (a imagem fica num overlay de um `Color.clear`, para não esticar o +/// layout). Some com Reduzir Transparência ou se o perfil desligar. +struct AmbientBackdrop: View { + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Color.clear + .overlay { + if !reduceTransparency, model.preferences.ambientBackground, let game = model.ambientGame { + Group { + if let image = model.covers.ambientImage(for: game.coverURL) { + Image(nsImage: image) + .resizable() + .interpolation(.medium) + .scaledToFill() + .blur(radius: 24) + } else { + Theme.coverTone(game.systemID) + } + } + .opacity(colorScheme == .dark ? 0.45 : 0.32) + .id(game.coverURL?.absoluteString ?? game.id) + .transition(.opacity) + .task(id: game.coverURL) { + if let url = game.coverURL { await model.covers.loadAmbient(url) } + } + } + } + .overlay { + // Mantém o texto legível sobre qualquer capa. + LinearGradient( + colors: [Theme.background.opacity(0.5), Theme.background.opacity(0.82), Theme.background], + startPoint: .top, endPoint: .bottom + ) + } + .clipped() + .ignoresSafeArea() + .animation(Motion.ambient, value: model.ambientGame?.id) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +/// Fundo escurecido atrás de um cartão (detalhes, edição de perfil); clicar fora fecha. +struct ModalDim: View { + let onDismiss: () -> Void + + var body: some View { + Color.black.opacity(0.42) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture(perform: onDismiss) + .accessibilityHidden(true) + } +} + +extension View { + /// Aparência de cartão flutuante. + func modalCard() -> some View { + clipShape(RoundedRectangle.rx(Radius.xl)) + .overlay(RoundedRectangle.rx(Radius.xl).strokeBorder(Theme.divider, lineWidth: 1)) + .shadow(color: .black.opacity(0.35), radius: 40, y: 20) + } +} + +extension AnyTransition { + /// O cartão cresce de leve e sobe enquanto aparece. + static var modalCard: AnyTransition { + .scale(scale: 0.94).combined(with: .opacity).combined(with: .offset(y: 16)) + } + + /// Troca entre abas: o conteúdo novo entra pelo lado da aba escolhida. + static func screen(direction: Int) -> AnyTransition { + .asymmetric( + insertion: .offset(x: CGFloat(direction) * 48).combined(with: .opacity), + removal: .offset(x: CGFloat(-direction) * 48).combined(with: .opacity) + ) + } + + /// Entrar no perfil: a tela inicial aproxima. + static var zoom: AnyTransition { + .asymmetric(insertion: .scale(scale: 1.04).combined(with: .opacity), removal: .scale(scale: 0.96).combined(with: .opacity)) + } +} diff --git a/Sources/RetroXApp/DesignSystem/Brand.swift b/Sources/RetroXApp/DesignSystem/Brand.swift new file mode 100644 index 0000000..80f97ae --- /dev/null +++ b/Sources/RetroXApp/DesignSystem/Brand.swift @@ -0,0 +1,155 @@ +import AppKit +import CoreText +import RetroXCore +import SwiftUI + +/// Marca do RetroX: a Rayo e o nome com o "X" em gradiente. `mark` é o tile do ícone, sem a margem da grade +/// do macOS. As regras de composição são as do kit (docs/design/logo/README.md, scripts/brand-kit.swift). +enum Brand { + @MainActor static let mark = load("RayoMark") + + /// Raio do tile em fração do lado: o mesmo da grade de ícone do macOS (185/824). + static let tileRadius: CGFloat = 185.0 / 824.0 + /// Aperto extra entre "o" e "X", em fração do corpo: o X tem recuo lateral largo. + static let oxKern: CGFloat = -0.045 + + static func font(_ size: CGFloat) -> Font { + .system(size: size, weight: .heavy, design: .rounded) + } + + /// Medidas do nome num corpo: altura das maiúsculas, espaço entre a caixa do texto e o topo das maiúsculas, + /// e o recuo à esquerda do "R" (para o vão até o tile ser medido da tinta). + struct Metrics { + let capHeight: CGFloat + let aboveCaps: CGFloat + let leftBearing: CGFloat + + init(size: CGFloat) { + let base = NSFont.systemFont(ofSize: size, weight: .heavy) + let font = base.fontDescriptor.withDesign(.rounded).flatMap { NSFont(descriptor: $0, size: size) } ?? base + capHeight = font.capHeight + aboveCaps = font.ascender - font.capHeight + let line = CTLineCreateWithAttributedString(NSAttributedString(string: "R", attributes: [.font: font])) + leftBearing = CTLineGetImageBounds(line, nil).minX + } + } + + private static func load(_ name: String) -> NSImage? { + guard let url = Bundle.module.url(forResource: name, withExtension: "png", subdirectory: "Resources") else { return nil } + return NSImage(contentsOf: url) + } +} + +/// O nome: "Retro" na cor do texto e o X da marca, com o vão "o→X" corrigido. +struct BrandWordmark: View { + let size: CGFloat + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 0) { + Text("Retro") + .foregroundStyle(Theme.textPrimary) + .font(Brand.font(size)) + .kerning(-0.015 * size) + BrandX(size: size) + .padding(.leading, Brand.oxKern * size) + } + } +} + +/// O glifo "X" num corpo: o contorno já nas coordenadas do Canvas (y para baixo, a partir do topo do X), a caixa +/// original (y para cima, a partir da linha de base) e o avanço. +struct BrandXGeometry { + let path: Path + let bounds: CGRect + let advance: CGFloat + + /// Guardada por corpo, porque o `body` pode ser recalculado a toda hora. + @MainActor private static var cache: [CGFloat: BrandXGeometry] = [:] + + @MainActor static func of(size: CGFloat) -> BrandXGeometry { + if let cached = cache[size] { return cached } + let geometry = BrandXGeometry(size: size) + cache[size] = geometry + return geometry + } + + private init(size: CGFloat) { + let base = NSFont.systemFont(ofSize: size, weight: .heavy) + let font = (base.fontDescriptor.withDesign(.rounded).flatMap { NSFont(descriptor: $0, size: size) } ?? base) as CTFont + var character = UniChar(88) // "X" + var glyph = CGGlyph() + CTFontGetGlyphsForCharacters(font, &character, &glyph, 1) + let glyphPath = CTFontCreatePathForGlyph(font, glyph, nil) ?? CGPath(rect: .zero, transform: nil) + let b = glyphPath.boundingBoxOfPath + bounds = b + advance = CTFontGetAdvancesForGlyphs(font, .horizontal, &glyph, nil, 1) + path = Path(glyphPath).applying(CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: b.maxY)) + } +} + +/// O X da marca (docs/design/logo/README.md): a letra com a cor de destaque do tema. No escuro leva um gradiente +/// até o céu da Rayo, misturado em OKLCH, do canto de cima à esquerda ao de baixo à direita; no claro é sólido. +struct BrandX: View { + let size: CGFloat + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let geometry = BrandXGeometry.of(size: size) + let b = geometry.bounds + let dark = colorScheme == .dark + let palette = Theme.current.theme.palette(dark: dark, retroXAccent: Theme.current.accent) + let stops = Palette.brandXStops(palette, dark: dark).map { Color(red: $0.red, green: $0.green, blue: $0.blue) } + // A cor de destaque segura até 30% e então caminha até o céu. + let gradient = Gradient(stops: [.init(color: stops[0], location: 0)] + stops.enumerated().map { index, color in + .init(color: color, location: 0.3 + 0.7 * CGFloat(index) / CGFloat(stops.count - 1)) + }) + Canvas { context, _ in + context.fill(geometry.path, with: .linearGradient( + gradient, startPoint: CGPoint(x: b.minX, y: 0), endPoint: CGPoint(x: b.maxX, y: b.height))) + } + .frame(width: geometry.advance, height: b.height) + .alignmentGuide(.firstTextBaseline) { _ in b.maxY } + .accessibilityHidden(true) + } +} + +/// Assinatura horizontal: tile = 2 × altura das maiúsculas, vão = 0,75 × altura das maiúsculas (medido da +/// tinta do "R"), e a faixa das maiúsculas centrada no tile. +struct BrandLockup: View { + let size: CGFloat + + var body: some View { + let metrics = Brand.Metrics(size: size) + HStack(alignment: .center, spacing: 0.75 * metrics.capHeight - metrics.leftBearing) { + BrandMark(size: 2 * metrics.capHeight) + BrandWordmark(size: size) + .alignmentGuide(VerticalAlignment.center) { $0[.firstTextBaseline] - metrics.capHeight / 2 } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("RetroX") + } +} + +/// O tile da Rayo; sem o recurso (build sem o bundle de recursos), cai num "R" provisório. +struct BrandMark: View { + let size: CGFloat + + var body: some View { + if let mark = Brand.mark { + Image(nsImage: mark) + .resizable() + .interpolation(.high) + .frame(width: size, height: size) + .shadow(color: .black.opacity(0.18), radius: max(1, size * 0.08), y: size * 0.04) + } else { + RoundedRectangle(cornerRadius: size * Brand.tileRadius, style: .continuous) + .fill(Theme.accent) + .frame(width: size, height: size) + .overlay { + Text("R") + .font(Brand.font(size * 0.58)) + .foregroundStyle(Theme.onAccent) + } + } + } +} diff --git a/Sources/RetroXApp/DesignSystem/Components.swift b/Sources/RetroXApp/DesignSystem/Components.swift new file mode 100644 index 0000000..1435be1 --- /dev/null +++ b/Sources/RetroXApp/DesignSystem/Components.swift @@ -0,0 +1,215 @@ +import AppKit +import RetroXCore +import SwiftUI + +/// Ação principal: preenchida com a cor de tema. +struct PrimaryButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(Typography.bodyEmphasis) + .foregroundStyle(Theme.onAccent) + .padding(.horizontal, Spacing.l) + .frame(minHeight: 44) + .background(Theme.accent, in: Capsule()) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .opacity(isEnabled ? (configuration.isPressed ? 0.9 : 1) : 0.45) + .contentShape(Capsule()) + .animation(Motion.hover, value: configuration.isPressed) + } +} + +/// Ação secundária: superfície neutra, sem cor de tema. +struct SecondaryButtonStyle: ButtonStyle { + var isCompact = false + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(isCompact ? Typography.caption : Typography.bodyEmphasis) + .foregroundStyle(Theme.textPrimary) + .padding(.horizontal, isCompact ? Spacing.s + Spacing.xs : Spacing.m) + .frame(minHeight: isCompact ? 32 : 44) + .background(Theme.raised, in: Capsule()) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .opacity(isEnabled ? (configuration.isPressed ? 0.8 : 1) : 0.45) + .contentShape(Capsule()) + .animation(Motion.hover, value: configuration.isPressed) + } +} + +/// Botão redondo só com ícone (favoritar, detalhes). Sempre com rótulo para VoiceOver. +struct IconButtonStyle: ButtonStyle { + var isActive = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(isActive ? Theme.onAccent : Theme.textPrimary) + .frame(width: 44, height: 44) + .background(isActive ? Theme.accent : Theme.raised, in: Circle()) + .scaleEffect(configuration.isPressed ? 0.94 : 1) + .contentShape(Circle()) + .animation(Motion.hover, value: configuration.isPressed) + } +} + +/// Chip de filtro: selecionado ganha a cor de tema. +struct ChipButtonStyle: ButtonStyle { + let isSelected: Bool + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(Typography.caption) + .foregroundStyle(isSelected ? Theme.onAccent : Theme.textPrimary) + .padding(.horizontal, Spacing.s + Spacing.xs) + .frame(minHeight: 32) + .background(isSelected ? Theme.accent : Theme.raised, in: Capsule()) + .opacity(isEnabled ? (configuration.isPressed ? 0.8 : 1) : 0.4) + .contentShape(Capsule()) + .animation(Motion.hover, value: isSelected) + } +} + +/// Dica de comando na barra inferior: glifo do controle conectado ou tecla do teclado. +struct CommandHint: View { + /// Símbolos SF do controle conectado; `nil` mostra a tecla. + let glyphs: [String]? + let key: String + let label: String + + var body: some View { + HStack(spacing: Spacing.s) { + if let glyphs { + HStack(spacing: Spacing.xs) { + ForEach(glyphs, id: \.self) { glyph in + Image(systemName: glyph) + .font(.system(size: 17, weight: .semibold)) + } + } + } else { + Text(key) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .padding(.horizontal, Spacing.s) + .frame(minWidth: 24, minHeight: 22) + .background(Theme.raised, in: RoundedRectangle.rx(6)) + } + Text(label) + .font(Typography.caption) + } + .foregroundStyle(Theme.textSecondary) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(label): \(glyphs == nil ? key : String(localized: "botão do controle"))") + } +} + +/// Linha de verificação: ícone de estado + texto. +struct StatusLine: View { + enum Kind { + case ok, warning, info + + var symbol: String { + switch self { + case .ok: "checkmark.circle.fill" + case .warning: "exclamationmark.triangle.fill" + case .info: "info.circle" + } + } + } + + let kind: Kind + let text: String + + var body: some View { + Label { + Text(text) + .font(Typography.body) + .foregroundStyle(kind == .info ? Theme.textSecondary : Theme.textPrimary) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: kind.symbol) + .foregroundStyle(kind == .ok ? Theme.accent : Theme.textSecondary) + } + } +} + +/// Selo quadrado com a sigla do sistema (no lugar de logotipos de terceiros). +struct SystemBadge: View { + let system: GameSystemID + var size: CGFloat = 48 + + var body: some View { + Text(SystemCatalog.system(system)?.shortName ?? system.rawValue.uppercased()) + .font(.system(size: size * 0.3, weight: .heavy, design: .rounded)) + .foregroundStyle(Theme.onCover) + .frame(width: size, height: size) + .background(Theme.coverTone(system), in: RoundedRectangle.rx(Radius.m)) + .accessibilityHidden(true) + } +} + +/// Foto do perfil, ou as iniciais sobre a cor de tema quando não há foto. +struct AvatarView: View { + let profile: Profile + var size: CGFloat = 32 + @Environment(AppModel.self) private var model + + var body: some View { + ZStack { + if let url = model.profiles.avatarURL(for: profile), + let image = NSImage(contentsOf: url) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Theme.accent + Text(profile.initials) + .font(.system(size: size * 0.38, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.onAccent) + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + .id(model.profiles.avatarRevision) + .accessibilityHidden(true) + } +} + +/// Campo de busca arredondado, no lugar do campo padrão do sistema. +struct SearchField: View { + @Binding var text: String + var prompt: String + var isFocused: FocusState.Binding + + var body: some View { + HStack(spacing: Spacing.s) { + Image(systemName: "magnifyingglass") + .foregroundStyle(Theme.textSecondary) + .accessibilityHidden(true) + TextField(prompt, text: $text) + .textFieldStyle(.plain) + .font(Typography.body) + .foregroundStyle(Theme.textPrimary) + .focused(isFocused) + if !text.isEmpty { + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Theme.textSecondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Limpar busca") + } + } + .padding(.horizontal, Spacing.m) + .frame(height: 40) + .background(Theme.raised, in: Capsule()) + .overlay { + Capsule().strokeBorder(Theme.accent.opacity(isFocused.wrappedValue ? 1 : 0), lineWidth: 2) + } + .animation(Motion.hover, value: isFocused.wrappedValue) + } +} diff --git a/Sources/RetroXApp/DesignSystem/CoverArtView.swift b/Sources/RetroXApp/DesignSystem/CoverArtView.swift new file mode 100644 index 0000000..6735987 --- /dev/null +++ b/Sources/RetroXApp/DesignSystem/CoverArtView.swift @@ -0,0 +1,63 @@ +import RetroXCore +import SwiftUI + +/// Capa do jogo na proporção 5:7. Sem imagem local, desenha uma capa própria: tom do sistema, +/// sigla e título, e um disco em relevo discreto. Nada de arte de terceiros. +struct CoverArtView: View { + let game: Game + let width: CGFloat + @Environment(CoverImageStore.self) private var covers + + static let aspectRatio: CGFloat = 7 / 5 + + var body: some View { + ZStack { + if let image = covers.image(for: game.coverURL) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .transition(.opacity) + } else { + GeneratedCover(game: game, width: width) + } + } + .frame(width: width, height: width * Self.aspectRatio) + .clipShape(RoundedRectangle.rx(Radius.m)) + .overlay { + RoundedRectangle.rx(Radius.m) + .strokeBorder(.white.opacity(0.06), lineWidth: 1) + } + .task(id: game.coverURL) { + if let url = game.coverURL { await covers.load(url) } + } + .accessibilityHidden(true) + } +} + +private struct GeneratedCover: View { + let game: Game + let width: CGFloat + + var body: some View { + ZStack(alignment: .topLeading) { + Theme.coverTone(game.systemID) + Circle() + .strokeBorder(Theme.onCover.opacity(0.07), lineWidth: width * 0.09) + .frame(width: width * 0.9, height: width * 0.9) + .offset(x: width * 0.38, y: width * 0.78) + VStack(alignment: .leading, spacing: width * 0.04) { + Text(SystemCatalog.system(game.systemID)?.shortName ?? "") + .font(.system(size: max(width * 0.075, 9), weight: .heavy, design: .rounded)) + .tracking(1.5) + .foregroundStyle(Theme.onCover.opacity(0.75)) + Spacer(minLength: 0) + Text(game.title) + .font(.system(size: max(width * 0.12, 11), weight: .bold, design: .rounded)) + .foregroundStyle(Theme.onCover) + .lineLimit(4) + .minimumScaleFactor(0.7) + } + .padding(width * 0.09) + } + } +} diff --git a/Sources/RetroXApp/DesignSystem/CoverImageStore.swift b/Sources/RetroXApp/DesignSystem/CoverImageStore.swift new file mode 100644 index 0000000..cdc177e --- /dev/null +++ b/Sources/RetroXApp/DesignSystem/CoverImageStore.swift @@ -0,0 +1,52 @@ +import AppKit +import Observation +import RetroXCore + +/// Capas já reduzidas, decodificadas fora da main thread e guardadas em memória (ADR-0008). +@MainActor +@Observable +final class CoverImageStore { + private var images: [URL: NSImage] = [:] + /// Versões minúsculas para o fundo ambiente: ampliadas, já ficam desfocadas sem filtro caro. + private var ambientImages: [URL: NSImage] = [:] + @ObservationIgnored private var inFlight: Set = [] + @ObservationIgnored private var ambientInFlight: Set = [] + + nonisolated static let maxPixelSize = 900 + + func image(for url: URL?) -> NSImage? { + url.flatMap { images[$0] } + } + + func ambientImage(for url: URL?) -> NSImage? { + url.flatMap { ambientImages[$0] } + } + + func loadAmbient(_ url: URL) async { + guard ambientImages[url] == nil, ambientInFlight.insert(url).inserted else { return } + let cgImage = await Task.detached(priority: .utility) { + CoverThumbnailer.thumbnail(of: url, maxPixelSize: 36) + }.value + ambientInFlight.remove(url) + if let cgImage { + ambientImages[url] = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + } + } + + /// Esquece a imagem guardada (a capa foi trocada no mesmo endereço). + func invalidate(_ url: URL) { + images[url] = nil + ambientImages[url] = nil + } + + func load(_ url: URL) async { + guard images[url] == nil, inFlight.insert(url).inserted else { return } + let cgImage = await Task.detached(priority: .utility) { + CoverThumbnailer.thumbnail(of: url, maxPixelSize: Self.maxPixelSize) + }.value + inFlight.remove(url) + if let cgImage { + images[url] = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + } + } +} diff --git a/Sources/RetroXApp/DesignSystem/Theme.swift b/Sources/RetroXApp/DesignSystem/Theme.swift new file mode 100644 index 0000000..2085919 --- /dev/null +++ b/Sources/RetroXApp/DesignSystem/Theme.swift @@ -0,0 +1,191 @@ +import AppKit +import RetroXCore +import SwiftUI + +extension Color { + /// Cor que acompanha o modo claro/escuro, a partir de um par de cores. + init(light: HexColor, dark: HexColor) { + self.init(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let color = isDark ? dark : light + return NSColor(srgbRed: color.red, green: color.green, blue: color.blue, alpha: 1) + }) + } + + init(_ tone: Tone) { + self.init(light: tone.light, dark: tone.dark) + } +} + +/// Tema escolhido no perfil: esquema de cores e, no tema RetroX, o acento. +struct ThemeSelection: Equatable, Hashable { + var theme: ThemeID = .retrox + var accent: Palette.Accent = .coral +} + +/// Tokens de cor do tema ativo (docs/design/brand-spec.md). Trocar de tema reconstrói a árvore de views +/// (`RootView` usa o tema como identidade), então ler daqui no `body` é seguro. +@MainActor +enum Theme { + static var current = ThemeSelection() + + static var background: Color { color(\.background) } + static var surface: Color { color(\.surface) } + static var raised: Color { color(\.raised) } + static var textPrimary: Color { color(\.textPrimary) } + static var textSecondary: Color { color(\.textSecondary) } + static var divider: Color { color(\.divider) } + static var accent: Color { color(\.accent) } + static var onAccent: Color { color(\.onAccent) } + static let onCover = Color(Palette.onCover) + + static func coverTone(_ system: GameSystemID) -> Color { + Color(Palette.coverTone(for: system)) + } + + private static func color(_ key: KeyPath) -> Color { + let selection = current + return Color( + light: selection.theme.palette(dark: false, retroXAccent: selection.accent)[keyPath: key], + dark: selection.theme.palette(dark: true, retroXAccent: selection.accent)[keyPath: key] + ) + } +} + +/// Grade de 8 pt. +enum Spacing { + static let xxs: CGFloat = 2 + static let xs: CGFloat = 4 + static let s: CGFloat = 8 + static let m: CGFloat = 16 + static let l: CGFloat = 24 + static let xl: CGFloat = 32 + static let xxl: CGFloat = 48 +} + +/// Cantos sempre contínuos (`style: .continuous`), que leem mais suaves que arcos circulares. +enum Radius { + static let s: CGFloat = 8 + static let m: CGFloat = 12 + static let l: CGFloat = 16 + static let xl: CGFloat = 24 +} + +/// Fonte e tamanho escolhidos no perfil. +struct TextSettings: Equatable, Hashable { + var fontStyle: ProfilePreferences.FontStyle = .rounded + var scale: Double = 1 +} + +/// No máximo quatro tamanhos por tela: título, seção, corpo e legenda. A fonte e o tamanho vêm do perfil; +/// trocar reconstrói a árvore de views (ver `AppModel.styleIdentity`). +@MainActor +enum Typography { + static var settings = TextSettings() + + static var display: Font { title(34, .bold) } + static var heading: Font { title(20, .semibold) } + static var body: Font { text(15, .regular) } + static var bodyEmphasis: Font { text(15, .semibold) } + static var caption: Font { text(12, .medium) } + + /// Títulos usam o estilo escolhido. + static func title(_ size: CGFloat, _ weight: Font.Weight) -> Font { + .system(size: size * settings.scale, weight: weight, design: design) + } + + /// No estilo arredondado, o corpo continua na fonte padrão, que lê melhor em tamanhos pequenos. + static func text(_ size: CGFloat, _ weight: Font.Weight) -> Font { + .system(size: size * settings.scale, weight: weight, design: settings.fontStyle == .rounded ? .default : design) + } + + private static var design: Font.Design { + switch settings.fontStyle { + case .rounded: .rounded + case .standard: .default + case .serif: .serif + case .mono: .monospaced + } + } +} + +/// Nível e velocidade das animações escolhidos no perfil, somados ao Reduzir Movimento do macOS. +struct MotionSettings: Equatable, Hashable { + var level: ProfilePreferences.AnimationLevel = .full + var factor: Double = 1 +} + +@MainActor +enum Motion { + static var settings = MotionSettings() + + private static var factor: Double { settings.factor } + + /// Foco e seleção: rápido, com um quique quase imperceptível. + static var focus: Animation? { + switch settings.level { + case .full: .snappy(duration: 0.32 * factor, extraBounce: 0.05) + case .reduced: .easeOut(duration: 0.15 * factor) + case .off: nil + } + } + + /// Passar o mouse e apertar botões. + static var hover: Animation? { + switch settings.level { + case .full: .smooth(duration: 0.24 * factor) + case .reduced: .easeOut(duration: 0.12 * factor) + case .off: nil + } + } + + static var fade: Animation? { + settings.level == .off ? nil : .easeOut(duration: 0.2 * factor) + } + + /// Troca de tela e cartões que surgem: mola longa e macia, sem quique. + static var screen: Animation? { + switch settings.level { + case .full: .smooth(duration: 0.45 * factor) + case .reduced: .easeOut(duration: 0.2 * factor) + case .off: nil + } + } + + /// Fundo ambiente: troca lenta. + static var ambient: Animation? { + settings.level == .off ? nil : .easeInOut(duration: 0.7 * factor) + } + + /// Com Reduzir Movimento do macOS, só esmaece. + static func animation(reduceMotion: Bool) -> Animation? { + reduceMotion ? fade : focus + } + + /// Escala, deslocamento e inclinação só com animações completas e sem Reduzir Movimento. + static func allowsMovement(reduceMotion: Bool) -> Bool { + !reduceMotion && settings.level == .full + } +} + +/// O português é o idioma da interface; datas e tamanhos seguem a mesma língua. +enum AppLocale { + static let current = Locale(identifier: "pt_BR") +} + +extension AppearancePreference { + var colorScheme: ColorScheme? { + switch self { + case .system: nil + case .light: .light + case .dark: .dark + } + } +} + +extension RoundedRectangle { + /// Retângulo arredondado com cantos contínuos, o padrão do RetroX. + static func rx(_ radius: CGFloat) -> RoundedRectangle { + RoundedRectangle(cornerRadius: radius, style: .continuous) + } +} diff --git a/Sources/RetroXApp/DesignSystem/TopBar.swift b/Sources/RetroXApp/DesignSystem/TopBar.swift new file mode 100644 index 0000000..459edad --- /dev/null +++ b/Sources/RetroXApp/DesignSystem/TopBar.swift @@ -0,0 +1,115 @@ +import RetroXCore +import SwiftUI + +/// Barra superior fixa: abas (o conteúdo desliza por baixo dela), estado, relógio e perfil. +struct TopBar: View { + @Environment(AppModel.self) private var model + + var body: some View { + HStack(spacing: Spacing.m) { + // Tile (não círculo), para não ser confundido com a foto do perfil à direita. + BrandLockup(size: 20) + ScreenTabs() + .padding(.leading, Spacing.s) + Spacer() + if model.library.isScanning || model.plugins.entries.contains(where: \.isDiscovering) { + HStack(spacing: Spacing.s) { + ProgressView().controlSize(.small) + Text("Atualizando…") + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + } + .transition(.opacity) + } + if let controller = model.input.controllerName { + Image(systemName: "gamecontroller.fill") + .foregroundStyle(Theme.textSecondary) + .help(controller) + .accessibilityLabel("Controle conectado: \(controller)") + } + if model.preferences.showsClock { + TimelineView(.everyMinute) { context in + Text(context.date, format: .dateTime.hour().minute().locale(AppLocale.current)) + .font(Typography.bodyEmphasis) + .monospacedDigit() + .foregroundStyle(Theme.textPrimary) + } + } + ProfileMenu() + } + // Espaço para os botões da janela (a barra de título fica escondida). + .padding(.leading, 88) + .padding(.trailing, Spacing.xl) + .padding(.top, Spacing.m) + .frame(height: 72) + .animation(Motion.fade, value: model.library.isScanning) + } +} + +/// As quatro abas, como pílulas; a selecionada desliza (LT/RT ou `[` `]` no teclado). +private struct ScreenTabs: View { + @Environment(AppModel.self) private var model + @Namespace private var selection + + var body: some View { + HStack(spacing: Spacing.xxs) { + if model.input.showsControllerGlyphs, let glyph = model.input.glyphs[.previousTab] { + Image(systemName: glyph).foregroundStyle(Theme.textSecondary).padding(.horizontal, Spacing.xs) + } + ForEach(AppModel.Screen.tabs, id: \.self) { screen in + tab(screen) + } + if model.input.showsControllerGlyphs, let glyph = model.input.glyphs[.nextTab] { + Image(systemName: glyph).foregroundStyle(Theme.textSecondary).padding(.horizontal, Spacing.xs) + } + } + .padding(Spacing.xs) + .background(Theme.raised, in: Capsule()) + } + + private func tab(_ screen: AppModel.Screen) -> some View { + let isSelected = model.screen == screen + return Button { + model.navigate(to: screen) + } label: { + Label(screen.title, systemImage: screen.symbol) + .font(Typography.caption) + .foregroundStyle(isSelected ? Theme.onAccent : Theme.textPrimary) + .padding(.horizontal, Spacing.m) + .frame(height: 32) + .background { + if isSelected { + Capsule().fill(Theme.accent).matchedGeometryEffect(id: "tab", in: selection) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +private struct ProfileMenu: View { + @Environment(AppModel.self) private var model + + var body: some View { + if let profile = model.profiles.current { + Menu { + Text(profile.name) + Button("Editar perfil…") { model.edit(profile) } + Button("Trocar de perfil") { model.showProfiles() } + Divider() + Button("Ajustes…") { model.navigate(to: .settings) } + } label: { + AvatarView(profile: profile, size: 36) + .overlay(Circle().strokeBorder(Theme.divider, lineWidth: 1)) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + .help(profile.name) + .accessibilityLabel("Perfil: \(profile.name)") + } + } +} diff --git a/Sources/RetroXApp/Features/Details/GameDetailsView.swift b/Sources/RetroXApp/Features/Details/GameDetailsView.swift new file mode 100644 index 0000000..25526ad --- /dev/null +++ b/Sources/RetroXApp/Features/Details/GameDetailsView.swift @@ -0,0 +1,318 @@ +import AppKit +import RetroXCore +import SwiftUI + +struct GameDetailsView: View { + let game: Game + @Environment(AppModel.self) private var model + + var body: some View { + let history = model.library.history(for: game) + VStack(spacing: 0) { + HStack(alignment: .top, spacing: Spacing.xl) { + VStack(spacing: Spacing.s) { + CoverArtView(game: game, width: 220) + .shadow(color: .black.opacity(0.2), radius: 16, y: 8) + HStack(spacing: Spacing.s) { + Button("Trocar capa…") { Task { await model.chooseCustomCover(for: game) } } + if model.library.hasCustomCover(game) { + Button("Original") { model.restoreOriginalCover(for: game) } + .help("Voltar para a capa encontrada nas pastas") + } + } + .buttonStyle(SecondaryButtonStyle(isCompact: true)) + } + ScrollView { + VStack(alignment: .leading, spacing: Spacing.l) { + header + facts(history) + MetadataSection(game: game) + DisclosureGroup("Comando de abertura") { + commandPlan + .padding(.top, Spacing.s) + } + .font(Typography.body) + .foregroundStyle(Theme.textPrimary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, Spacing.s) + } + .scrollIndicators(.automatic) + } + .padding(Spacing.xl) + + HStack(spacing: Spacing.s) { + Button { + model.play(game) + } label: { + Label("Jogar", systemImage: "play.fill") + } + .buttonStyle(PrimaryButtonStyle()) + .keyboardShortcut(.defaultAction) + Button { + model.library.toggleFavorite(game) + } label: { + Label(history.isFavorite ? "Favorito" : "Favoritar", systemImage: history.isFavorite ? "star.fill" : "star") + } + .buttonStyle(SecondaryButtonStyle()) + Button { + NSWorkspace.shared.activateFileViewerSelecting([game.romURL]) + } label: { + Label("Mostrar no Finder", systemImage: "folder") + } + .buttonStyle(SecondaryButtonStyle()) + Spacer() + Button("Fechar") { model.showDetails(nil) } + .buttonStyle(SecondaryButtonStyle()) + .keyboardShortcut(.cancelAction) + } + .padding(.horizontal, Spacing.xl) + .padding(.vertical, Spacing.m) + .background(Theme.background) + .overlay(alignment: .top) { Theme.divider.frame(height: 1) } + } + .frame(width: 880, height: 620) + .background(Theme.surface) + } + + private var header: some View { + VStack(alignment: .leading, spacing: Spacing.s) { + Text(GameText.overline(game, emulator: model.plugins.entry(game.pluginID)?.plugin.displayName)) + .font(Typography.caption) + .tracking(1.4) + .foregroundStyle(Theme.textSecondary) + Text(game.title) + .font(Typography.display) + .foregroundStyle(Theme.textPrimary) + .fixedSize(horizontal: false, vertical: true) + if let official = game.info?.name, TitleParser.searchKey(official) != TitleParser.searchKey(TitleParser.searchTitle(game.title)) { + Text("Nome no \(model.plugins.entry(game.pluginID)?.plugin.displayName ?? "emulador"): \(official)") + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + } + } + } + + private func facts(_ history: GameHistory) -> some View { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: Spacing.m, verticalSpacing: Spacing.s) { + row(String(localized: "Arquivo"), game.romURL.lastPathComponent) + row(String(localized: "Pasta"), (game.romURL.deletingLastPathComponent().path(percentEncoded: false) as NSString).abbreviatingWithTildeInPath) + row(String(localized: "Tamanho"), GameText.size(game)) + if let serial = game.serial { row(String(localized: "Serial"), serial) } + if let region = game.info?.region { row(String(localized: "Região"), region) } + if let compatibility = game.info?.compatibility { + GridRow { + label(String(localized: "Compatibilidade")) + HStack(spacing: Spacing.xxs) { + ForEach(1...5, id: \.self) { star in + Image(systemName: star <= compatibility ? "star.fill" : "star") + .font(.system(size: 11)) + .foregroundStyle(star <= compatibility ? Theme.accent : Theme.textSecondary) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(compatibility) de 5 no \(model.plugins.entry(game.pluginID)?.plugin.displayName ?? "emulador")") + } + } + row(String(localized: "Emulador"), emulatorDescription) + row(String(localized: "Partidas"), playsDescription(history)) + } + } + + private func label(_ text: String) -> some View { + Text(text) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + } + + private func row(_ title: String, _ value: String) -> some View { + GridRow { + label(title) + Text(value) + .font(Typography.body) + .foregroundStyle(Theme.textPrimary) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + + private var emulatorDescription: String { + guard let entry = model.plugins.entry(game.pluginID) else { return "—" } + return [entry.plugin.displayName, entry.discovery?.app?.version].compactMap { $0 }.joined(separator: " ") + } + + private func playsDescription(_ history: GameHistory) -> String { + guard let last = history.lastPlayedAt else { return String(localized: "Nunca jogado") } + let date = last.formatted(.dateTime.day().month(.wide).hour().minute().locale(AppLocale.current)) + return history.playCount == 1 + ? String(localized: "1 vez · última em \(date)") + : String(localized: "\(history.playCount) vezes · última em \(date)") + } + + /// O argv exato que seria usado, para diagnóstico. Nada é executado aqui. + @ViewBuilder + private var commandPlan: some View { + switch model.plan(for: game) { + case .success(let prepared): + VStack(alignment: .leading, spacing: Spacing.xs) { + ForEach(Array(prepared.plan.arguments.enumerated()), id: \.offset) { index, argument in + Text("[\(index)] \(argument)") + } + } + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(Theme.textSecondary) + .textSelection(.enabled) + case .failure(let error): + StatusLine(kind: .warning, text: error.message) + } + } +} + +/// Informações de uma fonte pública: só com a ação do usuário, que escolhe o resultado certo (ADR-0014). +private struct MetadataSection: View { + let game: Game + @Environment(AppModel.self) private var model + + var body: some View { + let search = model.metadata + VStack(alignment: .leading, spacing: Spacing.m) { + HStack { + Text("Sobre o jogo") + .font(Typography.heading) + .foregroundStyle(Theme.textPrimary) + Spacer() + if !search.isActive { + Button(model.library.customization(for: game)?.metadata == nil ? "Buscar na Wikipedia" : "Buscar de novo") { + search.start(for: game) + } + .buttonStyle(SecondaryButtonStyle(isCompact: true)) + } + } + if search.isActive && search.gameID == game.id { + SearchPanel(game: game) + } else if let metadata = model.library.customization(for: game)?.metadata { + SavedMetadata(game: game, metadata: metadata) + } else { + Text("Sem informações extras. A busca usa a Wikipedia (sem conta) e você escolhe o resultado certo.") + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(Spacing.m) + .background(Theme.background.opacity(0.6), in: RoundedRectangle.rx(Radius.l)) + } +} + +private struct SearchPanel: View { + let game: Game + @Environment(AppModel.self) private var model + @FocusState private var isQueryFocused: Bool + + var body: some View { + @Bindable var search = model.metadata + VStack(alignment: .leading, spacing: Spacing.s) { + HStack(spacing: Spacing.s) { + SearchField(text: $search.query, prompt: String(localized: "Nome do jogo"), isFocused: $isQueryFocused) + .onSubmit { Task { await search.search() } } + Button("Buscar") { Task { await search.search() } } + .buttonStyle(SecondaryButtonStyle(isCompact: true)) + Button("Cancelar") { search.cancel() } + .buttonStyle(SecondaryButtonStyle(isCompact: true)) + } + switch search.phase { + case .idle: + EmptyView() + case .searching: + HStack(spacing: Spacing.s) { + ProgressView().controlSize(.small) + Text("Buscando na Wikipedia…").font(Typography.body).foregroundStyle(Theme.textSecondary) + } + case .results(let candidates) where candidates.isEmpty: + Text("Nenhum resultado. Tente o nome sem número de versão ou em inglês.") + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + case .results(let candidates): + Text("Qual destes é o jogo?") + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + ForEach(candidates) { candidate in + Button { + Task { await search.choose(candidate, for: game, library: model.library) } + } label: { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text("\(candidate.title) · \(candidate.language.uppercased())") + .font(Typography.bodyEmphasis) + .foregroundStyle(Theme.textPrimary) + Text(candidate.snippet) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Spacing.s + Spacing.xs) + .background(Theme.raised, in: RoundedRectangle.rx(Radius.m)) + .contentShape(RoundedRectangle.rx(Radius.m)) + } + .buttonStyle(.plain) + } + case .loading(let candidate): + HStack(spacing: Spacing.s) { + ProgressView().controlSize(.small) + Text("Lendo “\(candidate.title)”…").font(Typography.body).foregroundStyle(Theme.textSecondary) + } + case .failed(let message): + StatusLine(kind: .warning, text: message) + } + } + .onAppear { isQueryFocused = false } + } +} + +private struct SavedMetadata: View { + let game: Game + let metadata: GameMetadata + @Environment(AppModel.self) private var model + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.s) { + if let description = metadata.shortDescription { + Text(description.prefix(1).uppercased() + description.dropFirst()) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + } + Text(metadata.summary) + .font(Typography.body) + .foregroundStyle(Theme.textPrimary) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: Spacing.m, verticalSpacing: Spacing.xs) { + if let year = metadata.releaseYear { fact(String(localized: "Lançamento"), String(year)) } + if !metadata.developers.isEmpty { fact(String(localized: "Desenvolvedora"), metadata.developers.joined(separator: ", ")) } + if !metadata.publishers.isEmpty { fact(String(localized: "Editora"), metadata.publishers.joined(separator: ", ")) } + if !metadata.genres.isEmpty { fact(String(localized: "Gênero"), metadata.genres.joined(separator: ", ")) } + } + .padding(.top, Spacing.xs) + HStack(spacing: Spacing.s) { + if let url = metadata.sourceURL { + Link("Fonte: \(metadata.sourceName) · CC BY-SA", destination: url) + .font(Typography.caption) + } else { + Text("Fonte: \(metadata.sourceName)").font(Typography.caption).foregroundStyle(Theme.textSecondary) + } + Spacer() + Button("Remover") { model.library.setMetadata(nil, for: game) } + .buttonStyle(SecondaryButtonStyle(isCompact: true)) + } + } + } + + private func fact(_ title: String, _ value: String) -> some View { + GridRow { + Text(title).font(Typography.caption).foregroundStyle(Theme.textSecondary) + Text(value).font(Typography.body).foregroundStyle(Theme.textPrimary) + } + } +} diff --git a/Sources/RetroXApp/Features/Emulators/EmulatorsView.swift b/Sources/RetroXApp/Features/Emulators/EmulatorsView.swift new file mode 100644 index 0000000..dd6c817 --- /dev/null +++ b/Sources/RetroXApp/Features/Emulators/EmulatorsView.swift @@ -0,0 +1,488 @@ +import AppKit +import RetroXCore +import SwiftUI + +/// Os emuladores como plugins: ativar, escolher a pasta raiz e ver o que foi encontrado nela. +/// Tudo alcançável pelo controle (↑ ↓ passa pelos botões, A ativa). +struct EmulatorsView: View { + @Environment(AppModel.self) private var model + @FocusState private var isFocused: Bool + + var body: some View { + VStack(spacing: 0) { + ScrollViewReader { scroll in + ScrollView { + VStack(alignment: .leading, spacing: Spacing.l) { + VStack(alignment: .leading, spacing: Spacing.s) { + Text("Emuladores") + .font(Typography.display) + .foregroundStyle(Theme.textPrimary) + Text("Ative os emuladores instalados neste Mac e diga onde eles estão. O RetroX só lê essas pastas: nada é copiado, movido ou alterado.") + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + .frame(maxWidth: 600, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + ForEach(model.plugins.entries) { entry in + PluginCard(entry: entry) + } + if !model.plugins.otherEmulators.isEmpty { + OtherEmulators() + } + } + .frame(maxWidth: 880, alignment: .leading) + .padding(.horizontal, Spacing.xxl) + .padding(.top, Spacing.m) + .padding(.bottom, Spacing.xxl) + .frame(maxWidth: .infinity, alignment: .leading) + } + .onChange(of: model.emulatorsFocus) { _, index in + let controls = model.emulatorControls + guard controls.indices.contains(index) else { return } + withAnimation(Motion.focus) { scroll.scrollTo(controls[index].id, anchor: .center) } + } + } + HintBar(items: [ + .init(actions: [.up, .down], key: "↑ ↓", label: String(localized: "Navegar")), + .init(action: .confirm, key: "↩", label: String(localized: "Escolher")), + .init(action: .back, key: "Esc", label: String(localized: "Voltar")), + ], showsMenu: false) + } + .focusable() + .focusEffectDisabled() + .focused($isFocused) + .onAppear { isFocused = true } + .onKeyPress(phases: [.down, .repeat]) { press in + guard !press.modifiers.contains(.command), let action = KeyMapping.action(for: press) else { return .ignored } + model.handle(action) + return .handled + } + } +} + +extension View { + /// Anel de foco para controles alcançados pelo teclado ou controle de jogo. + func controllerFocus(_ isFocused: Bool, radius: CGFloat = 26) -> some View { + overlay { + RoundedRectangle.rx(radius) + .strokeBorder(Theme.accent, lineWidth: 2.5) + .padding(-5) + .opacity(isFocused ? 1 : 0) + } + .scaleEffect(isFocused && Motion.settings.level == .full ? 1.03 : 1) + .animation(Motion.focus, value: isFocused) + } +} + +/// Ícone do app instalado (o mesmo que o Finder mostra), ou um selo genérico. +struct EmulatorIcon: View { + let appURL: URL? + var fallbackSystem: GameSystemID? + var size: CGFloat = 64 + + var body: some View { + Group { + if let appURL { + Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path(percentEncoded: false))) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + } else if let fallbackSystem { + SystemBadge(system: fallbackSystem, size: size * 0.86) + } else { + Image(systemName: "puzzlepiece.extension") + .font(.system(size: size * 0.36, weight: .semibold)) + .foregroundStyle(Theme.textSecondary) + .frame(width: size * 0.86, height: size * 0.86) + .background(Theme.raised, in: RoundedRectangle.rx(Radius.l)) + } + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +private struct PluginCard: View { + let entry: PluginsModel.Entry + @Environment(AppModel.self) private var model + + var body: some View { + let id = entry.id.rawValue + VStack(alignment: .leading, spacing: Spacing.l) { + HStack(alignment: .center, spacing: Spacing.m) { + EmulatorIcon(appURL: entry.discovery?.app?.bundleURL, fallbackSystem: entry.plugin.systems.first, size: 72) + VStack(alignment: .leading, spacing: Spacing.xs) { + HStack(spacing: Spacing.s) { + Text(entry.plugin.displayName) + .font(Typography.heading) + .foregroundStyle(Theme.textPrimary) + if let version = entry.discovery?.app?.version { + Text(version) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + .padding(.horizontal, Spacing.s) + .padding(.vertical, Spacing.xxs) + .background(Theme.raised, in: Capsule()) + } + } + Text(entry.plugin.summary) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + StatusPill(entry: entry) + } + Spacer() + Toggle("Ativado", isOn: Binding( + get: { entry.config.isEnabled }, + set: { isEnabled in Task { await model.setPlugin(entry.id, enabled: isEnabled) } } + )) + .toggleStyle(.switch) + .labelsHidden() + .tint(Theme.accent) + .padding(Spacing.xs) + .controllerFocus(model.isFocused(emulatorControl: "\(id).toggle")) + .id("\(id).toggle") + .accessibilityLabel("Ativar \(entry.plugin.displayName)") + } + + if entry.config.isEnabled { + RootFolderRow(entry: entry) + if entry.isDiscovering { + HStack(spacing: Spacing.s) { + ProgressView().controlSize(.small) + Text("Procurando o emulador e os jogos…") + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + } + .transition(.opacity) + } else if let discovery = entry.discovery { + DiscoverySummary(entry: entry, discovery: discovery) + .transition(.opacity.combined(with: .offset(y: 8))) + } + } + } + .padding(Spacing.l) + .background(Theme.surface, in: RoundedRectangle.rx(Radius.xl)) + .animation(Motion.screen, value: entry.config.isEnabled) + .animation(Motion.screen, value: entry.isDiscovering) + } +} + +private struct StatusPill: View { + let entry: PluginsModel.Entry + + var body: some View { + let (text, symbol, isGood): (String, String, Bool) = { + if !entry.config.isEnabled { return (String(localized: "Desativado"), "pause.circle", false) } + if entry.config.rootPath == nil { return (String(localized: "Escolha a pasta"), "folder.badge.questionmark", false) } + if entry.isDiscovering { return (String(localized: "Procurando…"), "magnifyingglass", false) } + if entry.isReady { return (String(localized: "Pronto"), "checkmark.circle.fill", true) } + return (String(localized: "Precisa de atenção"), "exclamationmark.triangle.fill", false) + }() + Label(text, systemImage: symbol) + .font(Typography.caption) + .foregroundStyle(isGood ? Theme.onAccent : Theme.textPrimary) + .padding(.horizontal, Spacing.s + Spacing.xxs) + .padding(.vertical, Spacing.xs) + .background(isGood ? Theme.accent : Theme.raised, in: Capsule()) + .contentTransition(.opacity) + } +} + +private struct RootFolderRow: View { + let entry: PluginsModel.Entry + @Environment(AppModel.self) private var model + + var body: some View { + let id = entry.id.rawValue + HStack(alignment: .center, spacing: Spacing.m) { + Image(systemName: "folder.fill") + .font(.system(size: 20)) + .foregroundStyle(Theme.accent) + .frame(width: 40, height: 40) + .background(Theme.raised, in: RoundedRectangle.rx(Radius.m)) + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text("Pasta do emulador") + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + Text(entry.rootURL.map(abbreviated) ?? String(localized: "Nenhuma pasta escolhida")) + .font(Typography.bodyEmphasis) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + Spacer() + Button("Escolher pasta…") { + Task { await model.chooseRoot(for: entry.id) } + } + .buttonStyle(SecondaryButtonStyle()) + .controllerFocus(model.isFocused(emulatorControl: "\(id).root")) + .id("\(id).root") + } + .padding(Spacing.m) + .background(Theme.background.opacity(0.5), in: RoundedRectangle.rx(Radius.l)) + } +} + +/// Resumo em blocos (app, jogos, capas, BIOS), avisos, ações e os detalhes recolhíveis. +private struct DiscoverySummary: View { + let entry: PluginsModel.Entry + let discovery: PluginDiscovery + @Environment(AppModel.self) private var model + + var body: some View { + let id = entry.id.rawValue + VStack(alignment: .leading, spacing: Spacing.m) { + if discovery.issues.contains(.rootNotFound) { + StatusLine(kind: .warning, text: PluginIssue.rootNotFound.message) + } else { + HStack(spacing: Spacing.s) { + StatTile(symbol: "app.badge.checkmark", value: discovery.app == nil ? "—" : String(localized: "Encontrado"), + label: discovery.app.map(appDetail) ?? String(localized: "App não encontrado"), isGood: discovery.app != nil) + StatTile(symbol: "gamecontroller", value: "\(gameCount)", label: String(localized: "jogos"), isGood: gameCount > 0) + StatTile(symbol: "photo.on.rectangle", value: "\(coverCount)/\(gameCount)", label: String(localized: "com capa"), isGood: coverCount > 0) + StatTile(symbol: "cpu", value: biosValue, label: String(localized: "BIOS"), isGood: biosIsGood) + } + ForEach(Array(warnings.enumerated()), id: \.offset) { _, warning in + StatusLine(kind: .warning, text: warning) + } + HStack(spacing: Spacing.s) { + Button { + Task { await model.rediscover(entry.id) } + } label: { + Label("Procurar de novo", systemImage: "arrow.clockwise") + } + .controllerFocus(model.isFocused(emulatorControl: "\(id).rediscover")) + .id("\(id).rediscover") + Button { + NSWorkspace.shared.activateFileViewerSelecting([discovery.root]) + } label: { + Label("Mostrar no Finder", systemImage: "folder") + } + .controllerFocus(model.isFocused(emulatorControl: "\(id).reveal")) + .id("\(id).reveal") + } + .buttonStyle(SecondaryButtonStyle()) + DisclosureGroup { + DetailsList(entry: entry, discovery: discovery) + .padding(.top, Spacing.s) + } label: { + Text("Pastas, BIOS e controle") + .font(Typography.bodyEmphasis) + .foregroundStyle(Theme.textPrimary) + } + } + } + } + + private var gameCount: Int { model.library.gameCount(for: entry.id) } + + private var coverCount: Int { + model.library.games.filter { $0.pluginID == entry.id && $0.coverURL != nil }.count + } + + private func appDetail(_ app: AppBundleInfo) -> String { + let architectures = app.architectures.map(\.description).sorted().joined(separator: ", ") + let viaRosetta = HostEnvironment.current().compatibility(with: app.architectures) == .rosetta + return viaRosetta ? String(localized: "\(architectures) · via Rosetta") : architectures + } + + private var biosValue: String { + switch discovery.bios { + case .configured: String(localized: "Configurada") + case .fileMissing: String(localized: "Ausente") + case .notConfigured: String(localized: "Falta") + case .unknown: "—" + } + } + + private var biosIsGood: Bool { + if case .configured = discovery.bios { return true } + return false + } + + private var warnings: [String] { + var result: [String] = [] + if discovery.romDirectories.isEmpty { + result.append(String(localized: "Nenhuma pasta de jogos. Crie uma pasta “roms” aqui ou configure a lista de jogos no próprio emulador.")) + } + switch discovery.bios { + case .fileMissing(let name): result.append(String(localized: "A BIOS configurada no emulador (\(name)) não foi encontrada.")) + case .notConfigured: result.append(String(localized: "BIOS não configurada: abra o emulador e conclua a configuração inicial.")) + default: break + } + for issue in discovery.issues { + if case .settingsFolderMissing = issue { result.append(issue.message) } + } + return result + } +} + +private struct StatTile: View { + let symbol: String + let value: String + let label: String + let isGood: Bool + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + Image(systemName: symbol) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(isGood ? Theme.accent : Theme.textSecondary) + Text(value) + .font(Typography.heading) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(label) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + .lineLimit(2) + } + .padding(Spacing.m) + .frame(maxWidth: .infinity, minHeight: 112, alignment: .topLeading) + .background(Theme.background.opacity(0.5), in: RoundedRectangle.rx(Radius.l)) + .accessibilityElement(children: .combine) + } +} + +private struct DetailsList: View { + let entry: PluginsModel.Entry + let discovery: PluginDiscovery + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.s) { + ForEach(discovery.romDirectories, id: \.self) { directory in + StatusLine(kind: .info, text: "\(abbreviated(directory.url)) · \(origin(directory.origin))") + } + ForEach(discovery.coverDirectories, id: \.self) { directory in + let isInRoot = directory.standardizedFileURL.path(percentEncoded: false) + .hasPrefix(discovery.root.standardizedFileURL.path(percentEncoded: false)) + StatusLine(kind: .info, text: "\(abbreviated(directory)) · \(isInRoot ? String(localized: "capas na pasta do emulador") : String(localized: "capas do \(entry.plugin.displayName)"))") + } + if case .configured(let name) = discovery.bios { + StatusLine(kind: .ok, text: String(localized: "BIOS: \(name)")) + } + if discovery.settingsFile != nil { + StatusLine(kind: .info, text: String(localized: "Lendo a configuração do \(entry.plugin.displayName) (só leitura).")) + } + if let controller = discovery.controller { + ControllerMapping(controller: controller, emulator: entry.plugin.displayName) + .padding(.top, Spacing.s) + } + } + } + + private func origin(_ origin: ROMDirectory.Origin) -> String { + switch origin { + case .rootFolder: String(localized: "jogos na pasta do emulador") + case .emulatorSettings: String(localized: "da lista de jogos do \(entry.plugin.displayName)") + } + } +} + +/// Emuladores reconhecidos sem plugin: o planejado (Dolphin) e os que já estão instalados neste Mac. +private struct OtherEmulators: View { + @Environment(AppModel.self) private var model + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.m) { + Text("Outros emuladores") + .font(Typography.heading) + .foregroundStyle(Theme.textPrimary) + .padding(.top, Spacing.s) + LazyVGrid(columns: [GridItem(.flexible(), spacing: Spacing.m), GridItem(.flexible(), spacing: Spacing.m)], spacing: Spacing.m) { + ForEach(model.plugins.otherEmulators, id: \.0.id) { emulator, app in + HStack(spacing: Spacing.m) { + EmulatorIcon(appURL: app, size: 48) + .saturation(app == nil ? 0 : 1) + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(emulator.name) + .font(Typography.bodyEmphasis) + .foregroundStyle(Theme.textPrimary) + Text(emulator.systems) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + } + Spacer() + Text(app == nil ? String(localized: "Em breve") : String(localized: "Instalado · plugin em breve")) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.trailing) + } + .padding(Spacing.m) + .background(Theme.surface.opacity(0.7), in: RoundedRectangle.rx(Radius.l)) + .accessibilityElement(children: .combine) + } + } + } + } +} + +/// O mapeamento do controle configurado no emulador, só para conferir (não é alterado aqui). +private struct ControllerMapping: View { + let controller: ControllerSummary + let emulator: String + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.s) { + StatusLine(kind: .ok, text: String(localized: "Controle no \(emulator): \(controller.type)\(controller.device.map { " · \($0)" } ?? "")")) + LazyVGrid(columns: [GridItem(.flexible(), spacing: Spacing.s), GridItem(.flexible(), spacing: Spacing.s)], alignment: .leading, spacing: Spacing.xs) { + ForEach(controller.bindings, id: \.self) { binding in + HStack(spacing: Spacing.s) { + Text(Self.ps2Name(binding.button)) + .font(Typography.bodyEmphasis) + .foregroundStyle(Theme.textPrimary) + .frame(width: 44, alignment: .leading) + Text(Self.inputName(binding.input)) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + } + } + } + .padding(.leading, Spacing.l) + } + } + + static func ps2Name(_ button: String) -> String { + switch button { + case "Cross": "×" + case "Circle": "○" + case "Square": "□" + case "Triangle": "△" + case "Up": "↑" + case "Down": "↓" + case "Left": "←" + case "Right": "→" + default: button + } + } + + /// "SDL-0/FaceSouth" → "botão de baixo (A / ×)". + static func inputName(_ input: String) -> String { + let name = input.split(separator: "/").last.map(String.init) ?? input + let names: [String: String] = [ + "FaceSouth": String(localized: "botão de baixo (A / ×)"), + "FaceEast": String(localized: "botão da direita (B / ○)"), + "FaceWest": String(localized: "botão da esquerda (X / □)"), + "FaceNorth": String(localized: "botão de cima (Y / △)"), + "DPadUp": String(localized: "direcional ↑"), + "DPadDown": String(localized: "direcional ↓"), + "DPadLeft": String(localized: "direcional ←"), + "DPadRight": String(localized: "direcional →"), + "LeftShoulder": "LB / L1", + "RightShoulder": "RB / R1", + "+LeftTrigger": "LT / L2", + "+RightTrigger": "RT / R2", + "LeftStick": String(localized: "clique do analógico esquerdo"), + "RightStick": String(localized: "clique do analógico direito"), + "Back": "Select / View", + "Start": "Start / Menu", + ] + return names[name] ?? input + } +} + +private func abbreviated(_ url: URL) -> String { + (url.path(percentEncoded: false) as NSString).abbreviatingWithTildeInPath +} diff --git a/Sources/RetroXApp/Features/Home/HomeStyles.swift b/Sources/RetroXApp/Features/Home/HomeStyles.swift new file mode 100644 index 0000000..87cb511 --- /dev/null +++ b/Sources/RetroXApp/Features/Home/HomeStyles.swift @@ -0,0 +1,218 @@ +import RetroXCore +import SwiftUI + +/// Carrossel: a capa em foco grande no centro, as vizinhas menores e inclinadas, deslizando com mola. +/// O jogo em foco tem título e ações embaixo. Com animações reduzidas, as vizinhas não inclinam. +struct CarouselHome: View { + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// Quantas capas de cada lado do foco ficam visíveis. + static let reach = 4 + + var body: some View { + let library = model.library + let games = library.visibleGames + let focused = library.focusedIndex ?? 0 + let moves = Motion.allowsMovement(reduceMotion: reduceMotion) + VStack(spacing: Spacing.m) { + ShelfTabs() + .padding(.top, Spacing.s) + GeometryReader { proxy in + let coverWidth = min(260 * model.preferences.tileSize.scale, (proxy.size.height - Spacing.xxl) / CoverArtView.aspectRatio) + ZStack { + ForEach(Array(games.enumerated()), id: \.element.id) { index, game in + let distance = Double(index - focused) + if abs(distance) <= Double(Self.reach) { + CarouselCover(game: game, width: coverWidth, distance: distance, moves: moves) + .onTapGesture { + if index == focused { model.play(game) } else { withAnimation(Motion.focus) { library.focus(game) } } + } + .transition(.opacity) + } + } + } + .frame(width: proxy.size.width, height: proxy.size.height) + .animation(Motion.focus, value: focused) + .animation(Motion.focus, value: library.shelf) + } + if let game = library.focusedGame { + FocusedGameCaption(game: game, centered: true) + .id(game.id) + .transition(.opacity) + .animation(Motion.fade, value: game.id) + } + } + .padding(.bottom, Spacing.l) + .gesture(DragGesture(minimumDistance: 24).onEnded { value in + let steps = Int((-value.translation.width / 120).rounded()) + if steps != 0 { withAnimation(Motion.focus) { library.moveFocus(by: steps) } } + }) + } +} + +private struct CarouselCover: View { + let game: Game + let width: CGFloat + let distance: Double + let moves: Bool + @Environment(AppModel.self) private var model + + var body: some View { + let magnitude = min(abs(distance), 3) + let side: Double = distance == 0 ? 0 : (distance > 0 ? 1 : -1) + let isFocused = distance == 0 + VStack(spacing: 0) { + CoverArtView(game: game, width: width) + .overlay { + RoundedRectangle.rx(Radius.m + 6) + .strokeBorder(Theme.accent, lineWidth: 3) + .padding(-7) + .opacity(isFocused ? 1 : 0) + } + .shadow(color: isFocused ? Theme.accent.opacity(0.4) : .black.opacity(0.25), radius: isFocused ? 30 : 12, y: 14) + Reflection(game: game, width: width) + .opacity(isFocused ? 1 : 0.4) + } + .scaleEffect(1 - magnitude * 0.16) + .rotation3DEffect(.degrees(moves ? -side * 38 : 0), axis: (x: 0, y: 1, z: 0), perspective: 0.55) + .offset(x: distance * width * 0.58 + side * width * 0.24) + .opacity(abs(distance) >= Double(CarouselHome.reach) ? 0 : 1 - magnitude * 0.18) + .zIndex(-abs(distance)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(GameAccessibility.label(game, isFavorite: model.library.isFavorite(game))) + .accessibilityAddTraits(isFocused ? [.isButton, .isSelected] : .isButton) + .accessibilityAction(named: "Jogar") { model.play(game) } + } +} + +/// Grade: o jogo em foco numa faixa no topo e todas as capas da prateleira embaixo. +struct GridHome: View { + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let library = model.library + let tileWidth = 120 * model.preferences.tileSize.scale + let focusedID = library.focusedGame?.id + VStack(alignment: .leading, spacing: Spacing.m) { + if let game = library.focusedGame { + FocusedGameCaption(game: game, centered: false) + .padding(.horizontal, Spacing.xxl) + .id(game.id) + .transition(.opacity) + .animation(Motion.fade, value: game.id) + } + ShelfTabs() + GeometryReader { proxy in + let spacing = Spacing.m + Spacing.xs + let columns = max(1, Int((proxy.size.width - 2 * Spacing.xxl + spacing) / (tileWidth + spacing))) + ScrollViewReader { scroll in + ScrollView { + LazyVGrid( + columns: Array(repeating: GridItem(.fixed(tileWidth), spacing: spacing), count: columns), + alignment: .leading, + spacing: spacing + ) { + ForEach(library.visibleGames) { game in + HomeGridTile(game: game, width: tileWidth, isFocused: game.id == focusedID) + .id(game.id) + } + } + .padding(.horizontal, Spacing.xxl) + .padding(.vertical, Spacing.m) + } + .onChange(of: columns, initial: true) { _, value in library.homeColumns = value } + .onChange(of: library.focusedGameID) { _, id in + guard let id else { return } + withAnimation(Motion.animation(reduceMotion: reduceMotion)) { scroll.scrollTo(id) } + } + } + } + } + .padding(.top, Spacing.s) + } +} + +private struct HomeGridTile: View { + let game: Game + let width: CGFloat + let isFocused: Bool + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let isHovered = model.browser.hoveredGameID == game.id + let lifts = Motion.allowsMovement(reduceMotion: reduceMotion) && (isFocused || isHovered) + CoverArtView(game: game, width: width) + .overlay { + RoundedRectangle.rx(Radius.m + 4) + .strokeBorder(Theme.accent, lineWidth: 3) + .padding(-5) + .opacity(isFocused ? 1 : 0) + } + .shadow(color: isFocused ? Theme.accent.opacity(0.35) : .black.opacity(isHovered ? 0.22 : 0.12), + radius: isFocused ? 16 : 6, y: 6) + .scaleEffect(lifts ? 1.04 : 1) + .animation(Motion.focus, value: isFocused) + .animation(Motion.hover, value: isHovered) + .onHover { inside in + model.browser.hoveredGameID = inside ? game.id : (model.browser.hoveredGameID == game.id ? nil : model.browser.hoveredGameID) + } + .onTapGesture { withAnimation(Motion.focus) { model.library.focus(game) } } + .simultaneousGesture(TapGesture(count: 2).onEnded { model.play(game) }) + .contextMenu { GameContextMenu(game: game) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(GameAccessibility.label(game, isFavorite: model.library.isFavorite(game))) + .accessibilityAddTraits(isFocused ? [.isButton, .isSelected] : .isButton) + .accessibilityAction(named: "Jogar") { model.play(game) } + } +} + +/// Título, metadados e ações do jogo em foco (carrossel e grade). +private struct FocusedGameCaption: View { + let game: Game + let centered: Bool + @Environment(AppModel.self) private var model + + var body: some View { + let history = model.library.history(for: game) + let stack = VStack(alignment: centered ? .center : .leading, spacing: Spacing.s) { + Text(game.title) + .font(Typography.heading) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + Text(GameText.metadata(game: game, history: history)) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + } + HStack(spacing: Spacing.m) { + if centered { Spacer() } + stack + if !centered { Spacer() } + HStack(spacing: Spacing.s) { + Button { + model.play(game) + } label: { + Label("Jogar", systemImage: "play.fill") + } + .buttonStyle(PrimaryButtonStyle()) + Button { + model.library.toggleFavorite(game) + } label: { + Image(systemName: history.isFavorite ? "star.fill" : "star") + } + .buttonStyle(IconButtonStyle(isActive: history.isFavorite)) + .accessibilityLabel(history.isFavorite ? "Remover dos favoritos" : "Favoritar") + Button { + model.showDetails(game) + } label: { + Image(systemName: "info") + } + .buttonStyle(IconButtonStyle()) + .accessibilityLabel("Detalhes") + } + if centered { Spacer() } + } + } +} diff --git a/Sources/RetroXApp/Features/Home/HomeView.swift b/Sources/RetroXApp/Features/Home/HomeView.swift new file mode 100644 index 0000000..d0ce5c4 --- /dev/null +++ b/Sources/RetroXApp/Features/Home/HomeView.swift @@ -0,0 +1,308 @@ +import RetroXCore +import SwiftUI + +/// Tela inicial no estilo de portátil: palco com o jogo em foco em cima, prateleira de capas embaixo. +/// Teclado e controle passam pelo mesmo roteador (`AppModel.handle`). +struct HomeView: View { + @Environment(AppModel.self) private var model + @FocusState private var isFocused: Bool + + var body: some View { + VStack(spacing: 0) { + content + HintBar(items: [ + .init(action: .confirm, key: "↩", label: String(localized: "Jogar")), + .init(action: .details, key: "Espaço", label: String(localized: "Detalhes")), + .init(action: .favorite, key: "F", label: String(localized: "Favoritar")), + .init(actions: [.previousShelf, .nextShelf], key: "Q E", label: String(localized: "Prateleira")), + ]) + } + .focusable() + .focusEffectDisabled() + .focused($isFocused) + .onAppear { isFocused = true } + .onChange(of: model.detailsGame) { _, game in + if game == nil { isFocused = true } + } + .onKeyPress(phases: [.down, .repeat]) { press in + guard !press.modifiers.contains(.command), let action = KeyMapping.action(for: press) else { return .ignored } + model.handle(action) + return .handled + } + } + + @ViewBuilder + private var content: some View { + let library = model.library + if !model.plugins.hasEnabledPlugin { + EmptyHome(kind: .noEmulator) + } else if !library.hasLoaded || (library.games.isEmpty && library.isScanning) { + EmptyHome(kind: .loading) + } else if library.games.isEmpty { + EmptyHome(kind: .noGames) + } else { + switch model.preferences.homeStyle { + case .shelf: + StageView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + ShelfView() + case .carousel: + CarouselHome() + case .grid: + GridHome() + } + } + } +} + +/// Teclas → ações, iguais em todas as telas. +enum KeyMapping { + static func action(for press: KeyPress) -> InputAction? { + switch press.key { + case .leftArrow: return .left + case .rightArrow: return .right + case .upArrow: return .up + case .downArrow: return .down + case .return: return .confirm + case .space: return .details + case .escape: return .back + default: break + } + switch press.characters.lowercased() { + case "[": return .previousTab + case "]": return .nextTab + case "f": return .favorite + case "q": return .previousShelf + case "e": return .nextShelf + case "m": return .menu + default: return nil + } + } +} + +private struct StageView: View { + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// A capa do palco acompanha a altura disponível (janela pequena ou tela cheia), até este limite. + static let maxCoverWidth: CGFloat = 240 + + var body: some View { + GeometryReader { proxy in + let coverWidth = min(Self.maxCoverWidth, max(120, (proxy.size.height - Spacing.xl) / CoverArtView.aspectRatio)) + ZStack(alignment: .leading) { + if let game = model.library.focusedGame { + HStack(alignment: .center, spacing: Spacing.xxl) { + CoverArtView(game: game, width: coverWidth) + .shadow(color: .black.opacity(0.22), radius: 24, y: 12) + StageDetails(game: game) + Spacer(minLength: 0) + } + .padding(.horizontal, Spacing.xxl) + .id(game.id) + .transition(.opacity.combined(with: .offset(x: Motion.allowsMovement(reduceMotion: reduceMotion) ? 12 : 0))) + } else { + EmptyShelfMessage(shelf: model.library.shelf) + .padding(.horizontal, Spacing.xxl) + } + } + .frame(width: proxy.size.width, height: proxy.size.height) + } + .padding(.bottom, Spacing.l) + .animation(Motion.animation(reduceMotion: reduceMotion), value: model.library.focusedGame?.id) + } +} + +private struct StageDetails: View { + let game: Game + @Environment(AppModel.self) private var model + + var body: some View { + let history = model.library.history(for: game) + VStack(alignment: .leading, spacing: Spacing.m) { + Text(GameText.overline(game, emulator: model.plugins.entry(game.pluginID)?.plugin.displayName)) + .font(Typography.caption) + .tracking(1.4) + .foregroundStyle(Theme.textSecondary) + Text(game.title) + .font(Typography.display) + .foregroundStyle(Theme.textPrimary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + Text(GameText.metadata(game: game, history: history)) + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + HStack(spacing: Spacing.s) { + Button { + model.play(game) + } label: { + Label("Jogar", systemImage: "play.fill") + } + .buttonStyle(PrimaryButtonStyle()) + Button { + model.library.toggleFavorite(game) + } label: { + Image(systemName: history.isFavorite ? "star.fill" : "star") + } + .buttonStyle(IconButtonStyle(isActive: history.isFavorite)) + .accessibilityLabel(history.isFavorite ? "Remover dos favoritos" : "Favoritar") + Button { + model.showDetails(game) + } label: { + Image(systemName: "info") + } + .buttonStyle(IconButtonStyle()) + .accessibilityLabel("Detalhes") + } + .padding(.top, Spacing.s) + } + .frame(maxWidth: 520, alignment: .leading) + } +} + +/// Textos de jogo repetidos em várias telas. +enum GameText { + static func overline(_ game: Game, emulator: String?) -> String { + let system = SystemCatalog.system(game.systemID)?.displayName ?? game.systemID.rawValue + return [system, emulator].compactMap { $0 }.joined(separator: " · ").uppercased() + } + + static func lastPlayed(_ history: GameHistory) -> String { + guard let last = history.lastPlayedAt else { return String(localized: "Ainda não jogado") } + let relative = RelativeDateTimeFormatter() + relative.locale = AppLocale.current + relative.unitsStyle = .full + return String(localized: "Jogado \(relative.localizedString(for: last, relativeTo: .now))") + } + + static func size(_ game: Game) -> String { + game.fileSize.formatted(.byteCount(style: .file).locale(AppLocale.current)) + } + + static func metadata(game: Game, history: GameHistory) -> String { + [lastPlayed(history), size(game), game.fileExtension].joined(separator: " · ") + } +} + +private struct EmptyShelfMessage: View { + let shelf: LibraryModel.Shelf + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.s) { + Text(shelf == .favorites ? "Nenhum favorito ainda" : "Nada por aqui") + .font(Typography.heading) + .foregroundStyle(Theme.textPrimary) + Text(shelf == .favorites + ? "Escolha um jogo em Todos e pressione F (ou o botão de favoritar do controle)." + : "Troque de prateleira com Q e E, ou com os botões de ombro do controle.") + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } +} + +/// Estados de tela inteira: sem emulador, procurando jogos, nenhum jogo encontrado. +struct EmptyHome: View { + enum Kind { + case noEmulator, loading, noGames + } + + let kind: Kind + @Environment(AppModel.self) private var model + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.m) { + if kind == .loading { + ProgressView() + .controlSize(.large) + } else { + Image(systemName: kind == .noEmulator ? "puzzlepiece.extension" : "tray") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(Theme.accent) + .accessibilityHidden(true) + } + Text(title) + .font(Typography.display) + .foregroundStyle(Theme.textPrimary) + Text(message) + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + .frame(maxWidth: 480, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + if kind != .loading { + Button(kind == .noEmulator ? "Ativar emuladores" : "Abrir Emuladores") { + model.navigate(to: .emulators) + } + .buttonStyle(PrimaryButtonStyle()) + .padding(.top, Spacing.s) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding(.horizontal, Spacing.xxl) + } + + private var title: String { + switch kind { + case .noEmulator: String(localized: "Nenhum emulador ativo") + case .loading: String(localized: "Procurando seus jogos…") + case .noGames: String(localized: "Nenhum jogo encontrado") + } + } + + private var message: String { + switch kind { + case .noEmulator: + String(localized: "Ative o PCSX2 e escolha a pasta dele. O RetroX encontra o app, os jogos e as capas sozinho, sem copiar nem alterar nada.") + case .loading: + String(localized: "Lendo as pastas dos emuladores ativos.") + case .noGames: + String(localized: "Os emuladores ativos não têm jogos nas pastas encontradas. Confira as pastas em Emuladores.") + } + } +} + +/// Barra de comandos no rodapé: mostra os glifos do controle conectado ou as teclas. +struct HintBar: View { + struct Item { + let actions: [InputAction] + let key: String + let label: String + + init(action: InputAction, key: String, label: String) { + self.init(actions: [action], key: key, label: label) + } + + init(actions: [InputAction], key: String, label: String) { + self.actions = actions + self.key = key + self.label = label + } + } + + let items: [Item] + var showsMenu = true + @Environment(AppModel.self) private var model + + var body: some View { + let glyphs = model.input.glyphs + HStack(spacing: Spacing.l) { + ForEach(items, id: \.label) { item in + // Atalho só de teclado (sem ação) ou controle sem glifo para todas as ações: mostra a tecla. + let symbols = item.actions.compactMap { glyphs[$0] } + let hasGlyphs = !item.actions.isEmpty && symbols.count == item.actions.count + CommandHint(glyphs: hasGlyphs ? symbols : nil, key: item.key, label: item.label) + } + Spacer() + if showsMenu { + CommandHint(glyphs: glyphs[.menu].map { [$0] }, key: "M", label: String(localized: "Ajustes")) + } + } + .padding(.horizontal, Spacing.xxl) + .frame(height: 48) + .background(Theme.surface) + .overlay(alignment: .top) { + Theme.divider.frame(height: 1) + } + } +} diff --git a/Sources/RetroXApp/Features/Home/ShelfView.swift b/Sources/RetroXApp/Features/Home/ShelfView.swift new file mode 100644 index 0000000..1cb6fef --- /dev/null +++ b/Sources/RetroXApp/Features/Home/ShelfView.swift @@ -0,0 +1,167 @@ +import RetroXCore +import SwiftUI + +/// Prateleira de capas. Detalhe de assinatura: a capa em foco sobe, ganha um halo na cor de tema +/// e se reflete no "chão" da prateleira. Com Reduce Motion, fica só o anel de foco. +struct ShelfView: View { + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + static let baseTileWidth: CGFloat = 128 + static let reflectionHeight: CGFloat = 32 + + var body: some View { + let library = model.library + let tileWidth = Self.baseTileWidth * model.preferences.tileSize.scale + VStack(alignment: .leading, spacing: Spacing.s) { + ShelfTabs() + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .bottom, spacing: Spacing.l) { + ForEach(library.visibleGames) { game in + ShelfTile(game: game, width: tileWidth, isFocused: game.id == library.focusedGame?.id) + .id(game.id) + } + } + .padding(.horizontal, Spacing.xxl) + .padding(.top, Spacing.xl) + } + .scrollClipDisabled() + .frame(height: tileWidth * CoverArtView.aspectRatio + Self.reflectionHeight + Spacing.xl + Spacing.s) + .background(alignment: .bottom) { ShelfFloor() } + .onChange(of: library.focusedGameID) { _, id in + guard let id else { return } + withAnimation(Motion.animation(reduceMotion: reduceMotion)) { + proxy.scrollTo(id, anchor: .center) + } + } + } + } + .padding(.bottom, Spacing.m) + } +} + +struct ShelfTabs: View { + @Environment(AppModel.self) private var model + @Namespace private var underline + + var body: some View { + let library = model.library + HStack(spacing: Spacing.l) { + ForEach(library.shelves) { shelf in + let isSelected = shelf == library.shelf + Button { + withAnimation(Motion.focus) { library.select(shelf) } + } label: { + VStack(spacing: Spacing.xs) { + Text(shelf.title) + .font(Typography.bodyEmphasis) + .foregroundStyle(isSelected ? Theme.textPrimary : Theme.textSecondary) + ZStack { + Capsule().fill(.clear).frame(width: 20, height: 3) + if isSelected { + Capsule() + .fill(Theme.accent) + .frame(width: 20, height: 3) + .matchedGeometryEffect(id: "underline", in: underline) + } + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + Spacer() + Text(countLabel(library.visibleGames.count)) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + } + .padding(.horizontal, Spacing.xxl) + } + + private func countLabel(_ count: Int) -> String { + count == 1 ? String(localized: "1 jogo") : String(localized: "\(count) jogos") + } +} + +private struct ShelfTile: View { + let game: Game + let width: CGFloat + let isFocused: Bool + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let isHovered = model.browser.hoveredGameID == game.id && !isFocused + let lifts = Motion.allowsMovement(reduceMotion: reduceMotion) && (isFocused || isHovered) + VStack(spacing: 0) { + CoverArtView(game: game, width: width) + .overlay { + RoundedRectangle.rx(Radius.m + 5) + .strokeBorder(Theme.accent, lineWidth: 3) + .padding(-6) + .opacity(isFocused ? 1 : 0) + } + .shadow( + color: isFocused ? Theme.accent.opacity(0.45) : .black.opacity(isHovered ? 0.24 : 0.14), + radius: isFocused ? 22 : (isHovered ? 12 : 6), + y: isFocused ? 10 : 4 + ) + .scaleEffect(lifts ? (isFocused ? 1.1 : 1.04) : 1, anchor: .bottom) + .offset(y: lifts ? (isFocused ? -6 : -2) : 0) + Reflection(game: game, width: width) + .opacity(isFocused ? 1 : 0) + } + .animation(Motion.animation(reduceMotion: reduceMotion), value: isFocused) + .animation(Motion.hover, value: isHovered) + .contentShape(Rectangle()) + .onHover { inside in + model.browser.hoveredGameID = inside ? game.id : (model.browser.hoveredGameID == game.id ? nil : model.browser.hoveredGameID) + } + .onTapGesture { withAnimation(Motion.focus) { model.library.focus(game) } } + .simultaneousGesture(TapGesture(count: 2).onEnded { model.play(game) }) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(isFocused ? [.isButton, .isSelected] : .isButton) + .accessibilityAction { model.library.focus(game) } + .accessibilityAction(named: "Jogar") { model.play(game) } + .accessibilityAction(named: "Favoritar") { model.library.toggleFavorite(game) } + } + + private var accessibilityLabel: String { + let system = SystemCatalog.system(game.systemID)?.shortName ?? "" + let favorite = model.library.isFavorite(game) ? String(localized: ", favorito") : "" + return "\(game.title), \(system)\(favorite)" + } +} + +/// O pé da capa espelhado e esmaecido, como num piso brilhante. +struct Reflection: View { + let game: Game + var width: CGFloat = ShelfView.baseTileWidth + + var body: some View { + CoverArtView(game: game, width: width) + .scaleEffect(x: 1, y: -1) + .frame(height: ShelfView.reflectionHeight, alignment: .top) + .clipped() + .mask { + LinearGradient(colors: [.black.opacity(0.3), .clear], startPoint: .top, endPoint: .bottom) + } + .padding(.top, 2) + .accessibilityHidden(true) + } +} + +/// O "chão" da prateleira: uma linha fina e uma faixa que escurece suavemente para baixo. +private struct ShelfFloor: View { + var body: some View { + VStack(spacing: 0) { + Theme.divider.frame(height: 1) + LinearGradient(colors: [Theme.raised.opacity(0.9), Theme.background], startPoint: .top, endPoint: .bottom) + } + .frame(height: ShelfView.reflectionHeight + Spacing.m) + .accessibilityHidden(true) + } +} diff --git a/Sources/RetroXApp/Features/Library/LibraryView.swift b/Sources/RetroXApp/Features/Library/LibraryView.swift new file mode 100644 index 0000000..e679495 --- /dev/null +++ b/Sources/RetroXApp/Features/Library/LibraryView.swift @@ -0,0 +1,429 @@ +import AppKit +import RetroXCore +import SwiftUI + +/// Todos os jogos em grade ou lista, com busca, filtro por console e favoritos. +struct LibraryView: View { + @Environment(AppModel.self) private var model + @FocusState private var isGridFocused: Bool + @FocusState private var isSearchFocused: Bool + + var body: some View { + VStack(spacing: 0) { + LibraryToolbar(isSearchFocused: $isSearchFocused) + content + HintBar(items: [ + .init(action: .confirm, key: "↩", label: String(localized: "Jogar")), + .init(action: .details, key: "Espaço", label: String(localized: "Detalhes")), + .init(action: .favorite, key: "F", label: String(localized: "Favoritar")), + .init(actions: [.previousShelf, .nextShelf], key: "Q E", label: String(localized: "Console")), + .init(actions: [], key: "/", label: String(localized: "Buscar")), + ]) + } + .focusable() + .focusEffectDisabled() + .focused($isGridFocused) + .onAppear { isGridFocused = true } + .onChange(of: model.detailsGame) { _, game in + if game == nil { isGridFocused = true } + } + .onKeyPress(phases: [.down, .repeat]) { press in + if isSearchFocused { + // Na busca, Esc e as setas para baixo voltam para a grade; o resto é texto. + guard press.key == .escape || press.key == .downArrow || press.key == .return else { return .ignored } + isSearchFocused = false + isGridFocused = true + return .handled + } + if press.characters == "/" || (press.modifiers.contains(.command) && press.characters == "f") { + isSearchFocused = true + return .handled + } + guard !press.modifiers.contains(.command), let action = KeyMapping.action(for: press) else { return .ignored } + model.handle(action) + return .handled + } + } + + @ViewBuilder + private var content: some View { + let library = model.library + if !model.plugins.hasEnabledPlugin { + EmptyHome(kind: .noEmulator) + } else if !library.hasLoaded { + EmptyHome(kind: .loading) + } else if library.games.isEmpty { + EmptyHome(kind: .noGames) + } else if model.browser.results.isEmpty { + NoResults() + } else { + LibraryGrid() + } + } +} + +private struct LibraryToolbar: View { + var isSearchFocused: FocusState.Binding + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var browser = model.browser + VStack(alignment: .leading, spacing: Spacing.m) { + HStack(alignment: .firstTextBaseline, spacing: Spacing.m) { + Text("Biblioteca") + .font(Typography.display) + .foregroundStyle(Theme.textPrimary) + Text(countLabel) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + Spacer() + SearchField(text: $browser.searchText, prompt: String(localized: "Buscar por nome ou serial"), isFocused: isSearchFocused) + .frame(width: 320) + } + HStack(spacing: Spacing.s) { + SystemChips() + Button { + withAnimation(Motion.hover) { browser.onlyFavorites.toggle() } + } label: { + Label("Favoritos", systemImage: browser.onlyFavorites ? "star.fill" : "star") + } + .buttonStyle(ChipButtonStyle(isSelected: browser.onlyFavorites)) + .accessibilityAddTraits(browser.onlyFavorites ? .isSelected : []) + if browser.isFiltering { + Button("Limpar filtros") { withAnimation(Motion.hover) { browser.clearFilters() } } + .buttonStyle(SecondaryButtonStyle(isCompact: true)) + .transition(.opacity) + } + Spacer() + Menu { + Picker("Ordenar por", selection: Binding(get: { browser.sort }, set: { browser.setSort($0) })) { + ForEach(LibraryBrowser.Sort.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.inline) + } label: { + Label(browser.sort.title, systemImage: "arrow.up.arrow.down") + .font(Typography.caption) + .foregroundStyle(Theme.textPrimary) + .padding(.horizontal, Spacing.s + Spacing.xs) + .frame(minHeight: 32) + .background(Theme.raised, in: Capsule()) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + .help("Ordenar") + LayoutPicker() + } + } + .padding(.horizontal, Spacing.xxl) + .padding(.bottom, Spacing.m) + .animation(Motion.hover, value: browser.isFiltering) + } + + private var countLabel: String { + let shown = model.browser.results.count + let total = model.library.games.count + return shown == total ? String(localized: "\(total) jogos") : String(localized: "\(shown) de \(total) jogos") + } +} + +/// "Todos" + os consoles que têm jogos, e um menu com o catálogo inteiro (os sem jogos ficam desativados). +private struct SystemChips: View { + @Environment(AppModel.self) private var model + + var body: some View { + let browser = model.browser + let counts = browser.systemCounts + HStack(spacing: Spacing.s) { + chip(String(localized: "Todos"), isSelected: browser.system == nil) { browser.system = nil } + ForEach(counts.filter { $0.1 > 0 }, id: \.0.id) { system, count in + chip("\(system.shortName) · \(count)", isSelected: browser.system == system.id) { browser.system = system.id } + } + Menu { + ForEach(counts, id: \.0.id) { system, count in + Button("\(system.displayName) (\(count))") { browser.system = system.id } + .disabled(count == 0) + } + } label: { + Label("Consoles", systemImage: "line.3.horizontal.decrease") + .font(Typography.caption) + .foregroundStyle(Theme.textPrimary) + .padding(.horizontal, Spacing.s + Spacing.xs) + .frame(minHeight: 32) + .background(Theme.raised, in: Capsule()) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + .help("Filtrar por console") + } + } + + private func chip(_ title: String, isSelected: Bool, action: @escaping () -> Void) -> some View { + Button(title) { withAnimation(Motion.hover) { action() } } + .buttonStyle(ChipButtonStyle(isSelected: isSelected)) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +private struct LayoutPicker: View { + @Environment(AppModel.self) private var model + @Namespace private var selection + + var body: some View { + let browser = model.browser + HStack(spacing: Spacing.xxs) { + ForEach(LibraryBrowser.Layout.allCases) { layout in + let isSelected = browser.layout == layout + Button { + withAnimation(Motion.focus) { browser.setLayout(layout) } + } label: { + Image(systemName: layout.symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(isSelected ? Theme.onAccent : Theme.textPrimary) + .frame(width: 36, height: 28) + .background { + if isSelected { + Capsule().fill(Theme.accent).matchedGeometryEffect(id: "layout", in: selection) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .help(layout.title) + .accessibilityLabel(layout.title) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + } + .padding(Spacing.xxs + 1) + .background(Theme.raised, in: Capsule()) + } +} + +private struct LibraryGrid: View { + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let browser = model.browser + let layout = browser.layout + let results = browser.results + let focusedID = browser.focusedGame?.id + GeometryReader { proxy in + let spacing = layout == .largeCovers ? Spacing.l : Spacing.m + Spacing.xs + let available = proxy.size.width - 2 * Spacing.xxl + let columns = layout == .list ? 1 : max(1, Int((available + spacing) / (layout.tileWidth + spacing))) + ScrollViewReader { scroll in + ScrollView { + Group { + if layout == .list { + LazyVStack(spacing: Spacing.xs) { + ForEach(results) { game in + GameRow(game: game, isFocused: game.id == focusedID).id(game.id) + } + } + } else { + LazyVGrid( + columns: Array(repeating: GridItem(.fixed(layout.tileWidth), spacing: spacing, alignment: .top), count: columns), + alignment: .leading, + spacing: layout == .titledCovers ? Spacing.l : spacing + ) { + ForEach(results) { game in + GridTile(game: game, layout: layout, isFocused: game.id == focusedID).id(game.id) + } + } + } + } + .padding(.horizontal, Spacing.xxl) + .padding(.vertical, Spacing.m) + } + .onChange(of: columns, initial: true) { _, value in browser.columns = value } + .onChange(of: browser.focusedGameID) { _, id in + guard let id else { return } + withAnimation(Motion.animation(reduceMotion: reduceMotion)) { scroll.scrollTo(id) } + } + } + } + .animation(Motion.focus, value: layout) + } +} + +/// Uma capa da grade. Em "Capas e nomes", com título e console embaixo. +private struct GridTile: View { + let game: Game + let layout: LibraryBrowser.Layout + let isFocused: Bool + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let isHovered = model.browser.hoveredGameID == game.id + let isFavorite = model.library.isFavorite(game) + VStack(alignment: .leading, spacing: Spacing.s) { + CoverArtView(game: game, width: layout.tileWidth) + .overlay(alignment: .topTrailing) { + if isFavorite && model.preferences.showsFavoriteBadge { + Image(systemName: "star.fill") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(Theme.onAccent) + .frame(width: 24, height: 24) + .background(Theme.accent, in: Circle()) + .padding(Spacing.s) + .accessibilityHidden(true) + } + } + .overlay { + RoundedRectangle.rx(Radius.m + 4) + .strokeBorder(Theme.accent, lineWidth: 3) + .padding(-5) + .opacity(isFocused ? 1 : 0) + } + .shadow( + color: isFocused ? Theme.accent.opacity(0.35) : .black.opacity(isHovered ? 0.22 : 0.12), + radius: isFocused ? 16 : (isHovered ? 12 : 5), + y: isFocused || isHovered ? 8 : 3 + ) + .scaleEffect(Motion.allowsMovement(reduceMotion: reduceMotion) && (isFocused || isHovered) ? 1.03 : 1) + if layout == .titledCovers { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(game.title) + .font(Typography.bodyEmphasis) + .foregroundStyle(Theme.textPrimary) + .lineLimit(2) + Text("\(SystemCatalog.system(game.systemID)?.shortName ?? "") · \(GameText.size(game))") + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + } + .frame(width: layout.tileWidth, alignment: .leading) + } + } + .animation(Motion.animation(reduceMotion: reduceMotion), value: isFocused) + .animation(Motion.hover, value: isHovered) + .contentShape(Rectangle()) + .onHover { inside in + model.browser.hoveredGameID = inside ? game.id : (model.browser.hoveredGameID == game.id ? nil : model.browser.hoveredGameID) + } + .onTapGesture { withAnimation(Motion.focus) { model.browser.focus(game) } } + .simultaneousGesture(TapGesture(count: 2).onEnded { model.play(game) }) + .contextMenu { GameContextMenu(game: game) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(GameAccessibility.label(game, isFavorite: isFavorite)) + .accessibilityAddTraits(isFocused ? [.isButton, .isSelected] : .isButton) + .accessibilityAction { model.browser.focus(game) } + .accessibilityAction(named: "Jogar") { model.play(game) } + .accessibilityAction(named: "Detalhes") { model.showDetails(game) } + .accessibilityAction(named: "Favoritar") { model.library.toggleFavorite(game) } + } +} + +/// Uma linha da lista: miniatura, nome, console, tamanho e última partida. +private struct GameRow: View { + let game: Game + let isFocused: Bool + @Environment(AppModel.self) private var model + + var body: some View { + let isHovered = model.browser.hoveredGameID == game.id + let history = model.library.history(for: game) + HStack(spacing: Spacing.m) { + CoverArtView(game: game, width: LibraryBrowser.Layout.list.tileWidth) + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(game.title) + .font(Typography.bodyEmphasis) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + Text(game.info?.name ?? game.romURL.lastPathComponent) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + .lineLimit(1) + } + Spacer(minLength: Spacing.m) + Text(SystemCatalog.system(game.systemID)?.shortName ?? "") + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + .frame(width: 56, alignment: .leading) + Text(GameText.size(game)) + .font(Typography.caption) + .monospacedDigit() + .foregroundStyle(Theme.textSecondary) + .frame(width: 72, alignment: .trailing) + Text(GameText.lastPlayed(history)) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + .frame(width: 160, alignment: .trailing) + Button { + model.library.toggleFavorite(game) + } label: { + Image(systemName: history.isFavorite ? "star.fill" : "star") + .foregroundStyle(history.isFavorite ? Theme.accent : Theme.textSecondary) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(history.isFavorite ? "Remover dos favoritos" : "Favoritar") + } + .padding(.horizontal, Spacing.m) + .padding(.vertical, Spacing.s) + .background(isFocused || isHovered ? Theme.raised : .clear, in: RoundedRectangle.rx(Radius.m)) + .overlay { + RoundedRectangle.rx(Radius.m) + .strokeBorder(Theme.accent, lineWidth: 2) + .opacity(isFocused ? 1 : 0) + } + .animation(Motion.hover, value: isHovered) + .animation(Motion.focus, value: isFocused) + .contentShape(Rectangle()) + .onHover { inside in + model.browser.hoveredGameID = inside ? game.id : (model.browser.hoveredGameID == game.id ? nil : model.browser.hoveredGameID) + } + .onTapGesture { model.browser.focus(game) } + .simultaneousGesture(TapGesture(count: 2).onEnded { model.play(game) }) + .contextMenu { GameContextMenu(game: game) } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(isFocused ? [.isButton, .isSelected] : .isButton) + .accessibilityAction(named: "Jogar") { model.play(game) } + } +} + +struct GameContextMenu: View { + let game: Game + @Environment(AppModel.self) private var model + + var body: some View { + Button("Jogar") { model.play(game) } + Button("Detalhes") { model.showDetails(game) } + Button(model.library.isFavorite(game) ? "Remover dos favoritos" : "Favoritar") { model.library.toggleFavorite(game) } + Divider() + Button("Trocar capa…") { Task { await model.chooseCustomCover(for: game) } } + Button("Mostrar no Finder") { NSWorkspace.shared.activateFileViewerSelecting([game.romURL]) } + } +} + +enum GameAccessibility { + static func label(_ game: Game, isFavorite: Bool) -> String { + let system = SystemCatalog.system(game.systemID)?.shortName ?? "" + return "\(game.title), \(system)\(isFavorite ? String(localized: ", favorito") : "")" + } +} + +private struct NoResults: View { + @Environment(AppModel.self) private var model + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.m) { + Text("Nenhum jogo com esses filtros") + .font(Typography.heading) + .foregroundStyle(Theme.textPrimary) + Text("Tente outro nome, outro console ou desligue o filtro de favoritos.") + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + Button("Limpar filtros") { model.browser.clearFilters() } + .buttonStyle(SecondaryButtonStyle()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.horizontal, Spacing.xxl) + .padding(.top, Spacing.xl) + } +} diff --git a/Sources/RetroXApp/Features/Profiles/ProfileEditorView.swift b/Sources/RetroXApp/Features/Profiles/ProfileEditorView.swift new file mode 100644 index 0000000..39ef9ad --- /dev/null +++ b/Sources/RetroXApp/Features/Profiles/ProfileEditorView.swift @@ -0,0 +1,222 @@ +import AppKit +import RetroXCore +import SwiftUI + +struct ProfileEditorView: View { + @Bindable var draft: ProfileDraft + @Environment(AppModel.self) private var model + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.l) { + Text(draft.isNew ? "Novo perfil" : "Editar perfil") + .font(Typography.heading) + .foregroundStyle(Theme.textPrimary) + + HStack(alignment: .top, spacing: Spacing.l) { + VStack(spacing: Spacing.s) { + DraftAvatar(draft: draft, size: 112) + Button("Escolher foto…") { + Task { + if let url = await FilePanel.chooseImage(message: String(localized: "Escolha uma foto para o perfil.")) { + draft.avatarSource = url + draft.removeAvatar = false + } + } + } + .buttonStyle(SecondaryButtonStyle(isCompact: true)) + if draft.avatarSource != nil || (draft.profile.avatarFile != nil && !draft.removeAvatar) { + Button("Remover foto") { + draft.avatarSource = nil + draft.removeAvatar = true + } + .buttonStyle(SecondaryButtonStyle(isCompact: true)) + } + } + VStack(alignment: .leading, spacing: Spacing.m) { + field(String(localized: "Nome")) { + TextField("Jogador", text: $draft.profile.name) + .textFieldStyle(.roundedBorder) + } + } + } + + VStack(alignment: .leading, spacing: Spacing.s) { + Text("Tema") + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + ThemeGallery( + selection: $draft.profile.theme, + accent: $draft.profile.accent, + columns: 4 + ) + Picker("Aparência", selection: $draft.profile.appearance) { + ForEach(AppearancePreference.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + .frame(maxWidth: 320) + .padding(.top, Spacing.s) + } + + HStack(spacing: Spacing.s) { + if !draft.isNew, model.profiles.profiles.count > 1 { + Button("Excluir perfil", role: .destructive) { model.delete(draft) } + .buttonStyle(SecondaryButtonStyle()) + } + Spacer() + Button("Cancelar") { model.editingProfile = nil } + .buttonStyle(SecondaryButtonStyle()) + .keyboardShortcut(.cancelAction) + Button(draft.isNew ? "Criar perfil" : "Salvar") { model.save(draft) } + .buttonStyle(PrimaryButtonStyle()) + .keyboardShortcut(.defaultAction) + } + } + .padding(Spacing.xl) + .frame(width: 720) + .background(Theme.surface) + } + + private func field(_ label: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + Text(label) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + content() + } + } +} + +/// Foto do perfil em edição: a nova escolhida, a atual, ou as iniciais. +private struct DraftAvatar: View { + let draft: ProfileDraft + let size: CGFloat + @Environment(AppModel.self) private var model + + var body: some View { + ZStack { + if let image = image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Theme.accent + Text(draft.profile.initials) + .font(.system(size: size * 0.38, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.onAccent) + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + .accessibilityHidden(true) + } + + private var image: NSImage? { + if let source = draft.avatarSource { return NSImage(contentsOf: source) } + if draft.removeAvatar { return nil } + return model.profiles.avatarURL(for: draft.profile).flatMap(NSImage.init(contentsOf:)) + } +} + +extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} + +/// Galeria de temas: cada cartão mostra a variante clara e a escura lado a lado. +struct ThemeGallery: View { + @Binding var selection: ThemeID + @Binding var accent: Palette.Accent + var columns = 4 + var showsAccents = true + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.m) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: Spacing.s), count: columns), spacing: Spacing.s) { + ForEach(ThemeID.allCases) { theme in + ThemeCard(theme: theme, accent: accent, isSelected: theme == selection) + .onTapGesture { withAnimation(Motion.focus) { selection = theme } } + } + } + if selection == .retrox && showsAccents { + HStack(spacing: Spacing.m) { + Text("Acento") + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + ForEach(Palette.Accent.allCases, id: \.self) { option in + Button { + accent = option + } label: { + Circle() + .fill(Color(option.fill)) + .frame(width: 22, height: 22) + .overlay { + Circle().strokeBorder(Theme.textPrimary, lineWidth: 2).padding(-5) + .opacity(option == accent ? 1 : 0) + } + .frame(width: 36, height: 36) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help(option.title) + .accessibilityLabel(option.title) + .accessibilityAddTraits(option == accent ? .isSelected : []) + } + } + } + } + } +} + +private struct ThemeCard: View { + let theme: ThemeID + let accent: Palette.Accent + let isSelected: Bool + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + HStack(spacing: 0) { + preview(dark: false) + preview(dark: true) + } + .frame(height: 56) + .clipShape(RoundedRectangle.rx(Radius.s)) + .overlay { + RoundedRectangle.rx(Radius.s + 4) + .strokeBorder(Theme.accent, lineWidth: 3) + .padding(-4) + .opacity(isSelected ? 1 : 0) + } + Text(theme.title) + .font(Typography.caption) + .foregroundStyle(isSelected ? Theme.textPrimary : Theme.textSecondary) + } + .padding(Spacing.xs) + .contentShape(Rectangle()) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Tema \(theme.title)") + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } + + /// Miniatura: fundo, um cartão com duas linhas de texto e o acento. + private func preview(dark: Bool) -> some View { + let palette = theme.palette(dark: dark, retroXAccent: accent) + return ZStack(alignment: .topLeading) { + Color(palette.background) + VStack(alignment: .leading, spacing: 3) { + Capsule().fill(Color(palette.textPrimary)).frame(width: 26, height: 4) + Capsule().fill(Color(palette.textSecondary)).frame(width: 18, height: 4) + Capsule().fill(Color(palette.accent)).frame(width: 22, height: 8) + .padding(.top, 3) + } + .padding(6) + .background(Color(palette.surface), in: RoundedRectangle.rx(5)) + .padding(6) + } + } +} + +extension Color { + /// Cor fixa (sem acompanhar o modo), para miniaturas que mostram claro e escuro ao mesmo tempo. + init(_ hex: HexColor) { + self.init(.sRGB, red: hex.red, green: hex.green, blue: hex.blue, opacity: 1) + } +} diff --git a/Sources/RetroXApp/Features/Profiles/ProfilePickerView.swift b/Sources/RetroXApp/Features/Profiles/ProfilePickerView.swift new file mode 100644 index 0000000..55e10a2 --- /dev/null +++ b/Sources/RetroXApp/Features/Profiles/ProfilePickerView.swift @@ -0,0 +1,131 @@ +import RetroXCore +import SwiftUI + +/// "Quem vai jogar?": a primeira tela, como num console. Cada perfil tem seus favoritos, recentes e tema. +struct ProfilePickerView: View { + @Environment(AppModel.self) private var model + @FocusState private var isFocused: Bool + + var body: some View { + VStack(spacing: 0) { + Spacer() + VStack(spacing: Spacing.s) { + Text("Quem vai jogar?") + .font(Typography.display) + .foregroundStyle(Theme.textPrimary) + Text("Cada perfil tem os próprios favoritos, recentes e tema.") + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + } + .padding(.bottom, Spacing.xxl) + HStack(alignment: .top, spacing: Spacing.xl) { + ForEach(Array(model.profiles.profiles.enumerated()), id: \.element.id) { index, profile in + ProfileTile(profile: profile, index: index) + } + AddProfileTile(index: model.profiles.profiles.count) + } + .padding(.horizontal, Spacing.xxl) + Spacer() + HintBar(items: [ + .init(actions: [.left, .right], key: "← →", label: String(localized: "Escolher")), + .init(action: .confirm, key: "↩", label: String(localized: "Entrar")), + .init(action: .details, key: "Espaço", label: String(localized: "Editar perfil")), + ], showsMenu: false) + } + .focusable() + .focusEffectDisabled() + .focused($isFocused) + .onAppear { isFocused = true } + .onChange(of: model.editingProfile == nil) { _, closed in + if closed { isFocused = true } + } + .onKeyPress(phases: [.down, .repeat]) { press in + guard !press.modifiers.contains(.command), let action = KeyMapping.action(for: press) else { return .ignored } + model.handle(action) + return .handled + } + } +} + +private struct ProfileTile: View { + let profile: Profile + let index: Int + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + static let avatarSize: CGFloat = 128 + + var body: some View { + let isFocused = model.profilePickerIndex == index + VStack(spacing: Spacing.m) { + AvatarView(profile: profile, size: Self.avatarSize) + .overlay { + Circle() + .strokeBorder(Theme.accent, lineWidth: 4) + .padding(-8) + .opacity(isFocused ? 1 : 0) + } + .shadow(color: isFocused ? Theme.accent.opacity(0.4) : .black.opacity(0.12), radius: isFocused ? 24 : 8, y: isFocused ? 10 : 4) + .scaleEffect(isFocused && Motion.allowsMovement(reduceMotion: reduceMotion) ? 1.08 : 1) + Text(profile.name) + .font(Typography.heading) + .foregroundStyle(isFocused ? Theme.textPrimary : Theme.textSecondary) + .lineLimit(1) + .frame(maxWidth: Self.avatarSize + Spacing.l) + } + .padding(Spacing.s) + .contentShape(Rectangle()) + .animation(Motion.animation(reduceMotion: reduceMotion), value: isFocused) + .onHover { inside in + if inside { model.profilePickerIndex = index } + } + .onTapGesture { model.enter(profile) } + .contextMenu { + Button("Entrar") { model.enter(profile) } + Button("Editar perfil…") { model.edit(profile) } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(profile.name) + .accessibilityAddTraits(isFocused ? [.isButton, .isSelected] : .isButton) + .accessibilityAction { model.enter(profile) } + .accessibilityAction(named: "Editar perfil") { model.edit(profile) } + } +} + +private struct AddProfileTile: View { + let index: Int + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let isFocused = model.profilePickerIndex == index + VStack(spacing: Spacing.m) { + Image(systemName: "plus") + .font(.system(size: 36, weight: .semibold)) + .foregroundStyle(isFocused ? Theme.textPrimary : Theme.textSecondary) + .frame(width: ProfileTile.avatarSize, height: ProfileTile.avatarSize) + .background(Theme.raised, in: Circle()) + .overlay { + Circle() + .strokeBorder(Theme.accent, lineWidth: 4) + .padding(-8) + .opacity(isFocused ? 1 : 0) + } + .scaleEffect(isFocused && Motion.allowsMovement(reduceMotion: reduceMotion) ? 1.08 : 1) + Text("Adicionar") + .font(Typography.heading) + .foregroundStyle(isFocused ? Theme.textPrimary : Theme.textSecondary) + } + .padding(Spacing.s) + .contentShape(Rectangle()) + .animation(Motion.animation(reduceMotion: reduceMotion), value: isFocused) + .onHover { inside in + if inside { model.profilePickerIndex = index } + } + .onTapGesture { model.newProfile() } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Adicionar perfil") + .accessibilityAddTraits(.isButton) + .accessibilityAction { model.newProfile() } + } +} diff --git a/Sources/RetroXApp/Features/Session/SessionOverlay.swift b/Sources/RetroXApp/Features/Session/SessionOverlay.swift new file mode 100644 index 0000000..ba48e6c --- /dev/null +++ b/Sources/RetroXApp/Features/Session/SessionOverlay.swift @@ -0,0 +1,127 @@ +import RetroXCore +import SwiftUI + +/// Cobre a tela enquanto o emulador abre e roda, e mostra como a partida terminou. +struct SessionOverlay: View { + @Environment(AppModel.self) private var model + + var body: some View { + ZStack { + Theme.background.opacity(0.94) + .ignoresSafeArea() + if let game { + HStack(alignment: .center, spacing: Spacing.xl) { + CoverArtView(game: game, width: 140) + .shadow(color: .black.opacity(0.2), radius: 16, y: 8) + VStack(alignment: .leading, spacing: Spacing.m) { + Text(overline) + .font(Typography.caption) + .tracking(1.4) + .foregroundStyle(Theme.textSecondary) + Text(game.title) + .font(Typography.heading) + .foregroundStyle(Theme.textPrimary) + .fixedSize(horizontal: false, vertical: true) + detail(for: game) + } + .frame(maxWidth: 420, alignment: .leading) + } + .padding(Spacing.xl) + .background(Theme.surface, in: RoundedRectangle.rx(Radius.l)) + .accessibilityElement(children: .contain) + } + } + } + + private var game: Game? { + switch model.session.phase { + case .idle: nil + case .launching(let game), .running(let game, _), .ended(let game, _), .failed(let game, _): game + } + } + + private var overline: String { + switch model.session.phase { + case .idle: "" + case .launching: String(localized: "Abrindo") + case .running(_, let emulator): String(localized: "Em jogo no \(emulator)") + case .ended: String(localized: "Partida encerrada") + case .failed: String(localized: "Não foi possível jogar") + } + } + + @ViewBuilder + private func detail(for game: Game) -> some View { + switch model.session.phase { + case .idle: + EmptyView() + case .launching: + HStack(spacing: Spacing.s) { + ProgressView().controlSize(.small) + Text("Preparando o emulador…") + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + } + case .running: + Text("O RetroX volta para cá quando o emulador fechar.") + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + ForEach(model.session.warnings, id: \.message) { warning in + StatusLine(kind: .info, text: warning.message) + } + Button("Trazer emulador para frente") { + model.session.bringEmulatorToFront() + } + .buttonStyle(SecondaryButtonStyle()) + case .ended(_, let after): + Text("Você jogou por \(after.formatted(.units(allowed: [.hours, .minutes, .seconds], width: .wide, maximumUnitCount: 2).locale(AppLocale.current))).") + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + Button("Continuar") { + model.session.dismiss() + } + .buttonStyle(PrimaryButtonStyle()) + case .failed(_, let error): + StatusLine(kind: .warning, text: error.message) + if error.recoveryActions.contains(.showRosettaHelp) { + Text("No Terminal: softwareupdate --install-rosetta") + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(Theme.textSecondary) + .textSelection(.enabled) + } + HStack(spacing: Spacing.s) { + ForEach(Array(error.recoveryActions.filter { $0 != .showRosettaHelp }.enumerated()), id: \.element) { index, action in + Button(title(for: action)) { + model.perform(action, for: game) + } + .buttonStyle(RecoveryButtonStyle(isPrimary: index == 0)) + } + Button("Fechar") { + model.session.dismiss() + } + .buttonStyle(SecondaryButtonStyle()) + } + } + } + + /// "Localizar arquivo" vira "Procurar jogos de novo": a biblioteca vem das pastas do emulador. + private func title(for action: RecoveryAction) -> String { + switch action { + case .locateROM: String(localized: "Procurar jogos de novo") + case .chooseEmulator, .openSettings: String(localized: "Abrir Emuladores") + default: action.title + } + } +} + +private struct RecoveryButtonStyle: ButtonStyle { + let isPrimary: Bool + + func makeBody(configuration: Configuration) -> some View { + if isPrimary { + PrimaryButtonStyle().makeBody(configuration: configuration) + } else { + SecondaryButtonStyle().makeBody(configuration: configuration) + } + } +} diff --git a/Sources/RetroXApp/Features/Settings/SettingsCatalog.swift b/Sources/RetroXApp/Features/Settings/SettingsCatalog.swift new file mode 100644 index 0000000..16c55ab --- /dev/null +++ b/Sources/RetroXApp/Features/Settings/SettingsCatalog.swift @@ -0,0 +1,177 @@ +import AppKit +import RetroXCore + +enum SettingsSection: String, CaseIterable, Identifiable { + case appearance, home, library, text, controller, animations, about + + var id: Self { self } + + var title: String { + switch self { + case .appearance: String(localized: "Aparência") + case .home: String(localized: "Tela inicial") + case .library: String(localized: "Biblioteca") + case .text: String(localized: "Texto") + case .controller: String(localized: "Controle") + case .animations: String(localized: "Animações") + case .about: String(localized: "Sobre") + } + } + + var symbol: String { + switch self { + case .appearance: "paintpalette" + case .home: "house" + case .library: "square.grid.2x2" + case .text: "textformat.size" + case .controller: "gamecontroller" + case .animations: "wand.and.stars" + case .about: "info.circle" + } + } +} + +/// Uma linha de ajuste. A mesma lista desenha a tela e responde ao controle (← → muda, A ativa). +struct SettingsRow: Identifiable { + enum Control { + case choice(options: [String], selected: Int, select: @MainActor (Int) -> Void) + case toggle(isOn: Bool, set: @MainActor (Bool) -> Void) + case action(label: String, run: @MainActor () -> Void) + case info(String) + } + + let id: String + let title: String + var detail: String? + let control: Control + + @MainActor + func step(by offset: Int) { + switch control { + case .choice(let options, let selected, let select): + select((selected + offset + options.count) % options.count) + case .toggle(let isOn, let set): + set(!isOn) + case .action, .info: + break + } + } + + @MainActor + func activate() { + switch control { + case .choice: step(by: 1) + case .toggle(let isOn, let set): set(!isOn) + case .action(_, let run): run() + case .info: break + } + } +} + +extension AppModel { + var settingsRows: [SettingsRow] { rows(for: settingsSection) } + + func rows(for section: SettingsSection) -> [SettingsRow] { + let preferences = self.preferences + switch section { + case .appearance: + guard let profile = profiles.current else { return [] } + var rows = [ + choice("theme", String(localized: "Tema"), ThemeID.allCases, profile.theme, \.title) { self.setTheme($0) }, + ] + if profile.theme == .retrox { + rows.append(choice("accent", String(localized: "Acento"), Palette.Accent.allCases, profile.accent, \.title) { self.setTheme(accent: $0) }) + } + rows.append(choice("appearance", String(localized: "Modo"), AppearancePreference.allCases, profile.appearance, \.title) { + self.setTheme(appearance: $0) + }) + rows.append(toggle("ambient", String(localized: "Fundo ambiente"), + detail: String(localized: "A capa do jogo em foco, desfocada, atrás da tela."), + preferences.ambientBackground) { value in self.updatePreferences { $0.ambientBackground = value } }) + return rows + case .home: + return [ + choice("homeStyle", String(localized: "Visualização"), ProfilePreferences.HomeStyle.allCases, preferences.homeStyle, \.title) { value in + self.updatePreferences { $0.homeStyle = value } + }, + choice("tileSize", String(localized: "Tamanho das capas"), ProfilePreferences.TileSize.allCases, preferences.tileSize, \.title) { value in + self.updatePreferences { $0.tileSize = value } + }, + choice("startShelf", String(localized: "Prateleira ao abrir"), ProfilePreferences.StartShelf.allCases, preferences.startShelf, \.title) { value in + self.updatePreferences { $0.startShelf = value } + }, + toggle("clock", String(localized: "Relógio na barra"), preferences.showsClock) { value in self.updatePreferences { $0.showsClock = value } }, + toggle("favoriteBadge", String(localized: "Estrela nos favoritos"), preferences.showsFavoriteBadge) { value in + self.updatePreferences { $0.showsFavoriteBadge = value } + }, + ] + case .library: + return [ + choice("libraryLayout", String(localized: "Visualização padrão"), LibraryBrowser.Layout.allCases, browser.layout, \.title) { + self.browser.setLayout($0) + }, + choice("librarySort", String(localized: "Ordem padrão"), LibraryBrowser.Sort.allCases, browser.sort, \.title) { + self.browser.setSort($0) + }, + ] + case .text: + return [ + choice("font", String(localized: "Fonte"), ProfilePreferences.FontStyle.allCases, preferences.fontStyle, \.title) { value in + self.updatePreferences { $0.fontStyle = value } + }, + choice("textSize", String(localized: "Tamanho do texto"), ProfilePreferences.TextSize.allCases, preferences.textSize, \.title) { value in + self.updatePreferences { $0.textSize = value } + }, + ] + case .controller: + return [ + choice("preset", String(localized: "Tipo de controle"), ControllerPreset.allCases, input.preset, \.title) { self.input.setPreset($0) }, + toggle("swap", String(localized: "Inverter confirmar e voltar"), + detail: String(localized: input.confirmsWithEastButton ? "Agora: confirma com o botão da direita." : "Agora: confirma com o botão de baixo."), + input.swapsConfirmAndBack) { self.input.setSwapsConfirmAndBack($0) }, + choice("repeat", String(localized: "Repetição ao segurar"), RepeatSpeed.allCases, input.repeatSpeed, \.title) { self.input.setRepeatSpeed($0) }, + choice("hints", String(localized: "Dicas de botões"), HintsMode.allCases, input.hintsMode, \.title) { self.input.setHintsMode($0) }, + ] + case .animations: + return [ + choice("animationLevel", String(localized: "Animações"), ProfilePreferences.AnimationLevel.allCases, preferences.animationLevel, \.title) { value in + self.updatePreferences { $0.animationLevel = value } + }, + choice("animationSpeed", String(localized: "Velocidade"), ProfilePreferences.AnimationSpeed.allCases, preferences.animationSpeed, \.title) { value in + self.updatePreferences { $0.animationSpeed = value } + }, + toggle("startup", String(localized: "Animação de abertura"), preferences.startupAnimation) { value in + self.updatePreferences { $0.startupAnimation = value } + }, + ] + case .about: + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" + return [ + SettingsRow(id: "version", title: String(localized: "Versão"), control: .info("RetroX \(version)")), + SettingsRow(id: "emulators", title: String(localized: "Emuladores ativos"), + control: .info(plugins.entries.filter(\.config.isEnabled).map(\.plugin.displayName).joined(separator: ", ").nilIfEmpty + ?? String(localized: "Nenhum"))), + SettingsRow(id: "data", title: String(localized: "Dados do RetroX"), detail: String(localized: "Perfis, favoritos, capas próprias e configuração dos emuladores."), + control: .action(label: String(localized: "Mostrar no Finder")) { + NSWorkspace.shared.activateFileViewerSelecting([RetroXDirectories.applicationSupport]) + }), + SettingsRow(id: "profiles", title: String(localized: "Perfis"), control: .action(label: String(localized: "Trocar de perfil")) { self.showProfiles() }), + ] + } + } + + private func choice( + _ id: String, _ title: String, _ options: [Value], _ selected: Value, _ name: KeyPath, + select: @escaping @MainActor (Value) -> Void + ) -> SettingsRow { + SettingsRow(id: id, title: title, control: .choice( + options: options.map { $0[keyPath: name] }, + selected: options.firstIndex(of: selected) ?? 0, + select: { select(options[$0]) } + )) + } + + private func toggle(_ id: String, _ title: String, detail: String? = nil, _ isOn: Bool, set: @escaping @MainActor (Bool) -> Void) -> SettingsRow { + SettingsRow(id: id, title: title, detail: detail, control: .toggle(isOn: isOn, set: set)) + } +} diff --git a/Sources/RetroXApp/Features/Settings/SettingsView.swift b/Sources/RetroXApp/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..4f6bb7a --- /dev/null +++ b/Sources/RetroXApp/Features/Settings/SettingsView.swift @@ -0,0 +1,311 @@ +import RetroXCore +import SwiftUI + +/// Ajustes dentro da janela, como num console: seções à esquerda, opções à direita, tudo pelo controle. +struct SettingsScreen: View { + @Environment(AppModel.self) private var model + @FocusState private var isFocused: Bool + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: Spacing.xl) { + SectionList() + .frame(width: 232) + ScrollView { + VStack(alignment: .leading, spacing: Spacing.s) { + Text(model.settingsSection.title) + .font(Typography.display) + .foregroundStyle(Theme.textPrimary) + .padding(.bottom, Spacing.s) + ForEach(Array(model.settingsRows.enumerated()), id: \.element.id) { index, row in + SettingsRowView(row: row, isFocused: model.showsListFocus && model.settingsRow == index) + .onTapGesture { model.settingsRow = index } + } + SectionExtras(section: model.settingsSection) + .padding(.top, Spacing.m) + } + .frame(maxWidth: 720, alignment: .leading) + .padding(.bottom, Spacing.xl) + .id(model.settingsSection) + .transition(.opacity.combined(with: .offset(y: 8))) + } + .scrollIndicators(.never) + } + .padding(.horizontal, Spacing.xxl) + .padding(.top, Spacing.m) + .animation(Motion.screen, value: model.settingsSection) + HintBar(items: [ + .init(actions: [.up, .down], key: "↑ ↓", label: String(localized: "Navegar")), + .init(actions: [.left, .right], key: "← →", label: String(localized: "Mudar")), + .init(action: .confirm, key: "↩", label: String(localized: "Escolher")), + .init(action: .back, key: "Esc", label: String(localized: "Voltar")), + ], showsMenu: false) + } + .focusable() + .focusEffectDisabled() + .focused($isFocused) + .onAppear { isFocused = true } + .onKeyPress(phases: [.down, .repeat]) { press in + guard !press.modifiers.contains(.command), let action = KeyMapping.action(for: press) else { return .ignored } + model.handle(action) + return .handled + } + } +} + +private struct SectionList: View { + @Environment(AppModel.self) private var model + @Namespace private var selection + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + ForEach(SettingsSection.allCases) { section in + let isSelected = model.settingsSection == section + let isFocused = isSelected && model.settingsRow == nil && model.showsListFocus + Button { + withAnimation(Motion.focus) { + model.settingsSection = section + model.settingsRow = nil + } + } label: { + Label(section.title, systemImage: section.symbol) + .font(Typography.bodyEmphasis) + .foregroundStyle(isSelected ? Theme.onAccent : Theme.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Spacing.m) + .frame(height: 44) + .background { + if isSelected { + RoundedRectangle.rx(Radius.m).fill(Theme.accent) + .matchedGeometryEffect(id: "section", in: selection) + } + } + .overlay { + RoundedRectangle.rx(Radius.m + 4) + .strokeBorder(Theme.accent, lineWidth: 2) + .padding(-4) + .opacity(isFocused ? 1 : 0) + } + .contentShape(RoundedRectangle.rx(Radius.m)) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + } + .padding(Spacing.s) + .background(Theme.surface, in: RoundedRectangle.rx(Radius.l)) + } +} + +struct SettingsRowView: View { + let row: SettingsRow + let isFocused: Bool + + var body: some View { + HStack(spacing: Spacing.m) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(row.title) + .font(Typography.bodyEmphasis) + .foregroundStyle(Theme.textPrimary) + if let detail = row.detail { + Text(detail) + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: Spacing.m) + control + } + .padding(.horizontal, Spacing.m) + .padding(.vertical, Spacing.s + Spacing.xs) + .frame(minHeight: 60) + .background(isFocused ? Theme.raised : Theme.surface, in: RoundedRectangle.rx(Radius.l)) + .overlay { + RoundedRectangle.rx(Radius.l) + .strokeBorder(Theme.accent, lineWidth: 2) + .opacity(isFocused ? 1 : 0) + } + .scaleEffect(isFocused && Motion.allowsMovement(reduceMotion: false) ? 1.01 : 1) + .animation(Motion.focus, value: isFocused) + .contentShape(RoundedRectangle.rx(Radius.l)) + } + + @ViewBuilder + private var control: some View { + switch row.control { + case .choice(let options, let selected, let select): + HStack(spacing: Spacing.xs) { + stepButton("chevron.left") { select((selected - 1 + options.count) % options.count) } + Text(options[selected]) + .font(Typography.bodyEmphasis) + .foregroundStyle(Theme.textPrimary) + .frame(minWidth: 168) + .contentTransition(.opacity) + .id(options[selected]) + .transition(.opacity) + stepButton("chevron.right") { select((selected + 1) % options.count) } + } + .padding(Spacing.xs) + .background(Theme.raised, in: Capsule()) + .animation(Motion.hover, value: selected) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(row.title): \(options[selected])") + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: select((selected + 1) % options.count) + case .decrement: select((selected - 1 + options.count) % options.count) + @unknown default: break + } + } + case .toggle(let isOn, let set): + Toggle(row.title, isOn: Binding(get: { isOn }, set: { value in set(value) })) + .toggleStyle(.switch) + .labelsHidden() + .tint(Theme.accent) + case .action(let label, let run): + Button(label) { run() } + .buttonStyle(SecondaryButtonStyle(isCompact: true)) + case .info(let text): + Text(text) + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + .textSelection(.enabled) + } + } + + private func stepButton(_ symbol: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 32, height: 32) + .background(Theme.surface, in: Circle()) + .contentShape(Circle()) + } + .buttonStyle(.plain) + } +} + +/// Conteúdo visual de cada seção, além das linhas: galeria de temas, prévia do texto, mapa do controle. +private struct SectionExtras: View { + let section: SettingsSection + @Environment(AppModel.self) private var model + + var body: some View { + switch section { + case .appearance: + if let profile = model.profiles.current { + VStack(alignment: .leading, spacing: Spacing.s) { + Text("Todos os temas") + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + ThemeGallery( + selection: Binding(get: { profile.theme }, set: { model.setTheme($0) }), + accent: Binding(get: { profile.accent }, set: { model.setTheme(accent: $0) }), + columns: 4, + showsAccents: false + ) + } + } + case .text: + VStack(alignment: .leading, spacing: Spacing.s) { + Text("Prévia") + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + VStack(alignment: .leading, spacing: Spacing.s) { + Text("Test Drive Unlimited") + .font(Typography.display) + Text("Corrida em mundo aberto · Jogado ontem · 4 GB") + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + } + .foregroundStyle(Theme.textPrimary) + .padding(Spacing.l) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surface, in: RoundedRectangle.rx(Radius.l)) + } + case .controller: + ControllerMap() + default: + EmptyView() + } + } +} + +/// Mapa dos botões no RetroX conforme o tipo de controle, e o último botão apertado. +private struct ControllerMap: View { + @Environment(AppModel.self) private var model + + var body: some View { + let input = model.input + let preset = input.effectivePreset + let confirm: ControllerControl = input.confirmsWithEastButton ? .east : .south + let back: ControllerControl = input.confirmsWithEastButton ? .south : .east + let entries: [(ControllerControl, String)] = [ + (confirm, String(localized: "Confirmar / jogar")), (back, String(localized: "Voltar")), + (.west, String(localized: "Detalhes")), (.north, String(localized: "Favoritar")), + (.leftShoulder, String(localized: "Prateleira ou console anterior")), (.rightShoulder, String(localized: "Próxima prateleira ou console")), + (.leftTrigger, String(localized: "Aba anterior")), (.rightTrigger, String(localized: "Próxima aba")), + (.menu, String(localized: "Ajustes")), (.options, String(localized: "Emuladores")), + ] + VStack(alignment: .leading, spacing: Spacing.m) { + HStack(spacing: Spacing.s) { + Image(systemName: "gamecontroller.fill") + .foregroundStyle(input.controllerName == nil ? Theme.textSecondary : Theme.accent) + Text(input.controllerName.map { String(localized: "Conectado: \($0)") } ?? String(localized: "Nenhum controle conectado. Mostrando \(preset.title).")) + .font(Typography.body) + .foregroundStyle(Theme.textPrimary) + } + LazyVGrid(columns: [GridItem(.flexible(), spacing: Spacing.s), GridItem(.flexible(), spacing: Spacing.s)], spacing: Spacing.s) { + ForEach(entries, id: \.0) { control, label in + HStack(spacing: Spacing.s) { + Image(systemName: preset.glyph(for: control) ?? "circle") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .frame(width: 32) + Text(label) + .font(Typography.body) + .foregroundStyle(Theme.textSecondary) + Spacer(minLength: 0) + } + .padding(Spacing.s + Spacing.xs) + .background(Theme.surface, in: RoundedRectangle.rx(Radius.m)) + } + } + HStack(spacing: Spacing.m) { + Text("Último botão") + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + Text(input.lastAction.map(Self.describe) ?? "—") + .font(Typography.bodyEmphasis) + .foregroundStyle(Theme.accent) + .id(input.actionCount) + .transition(.scale(scale: 0.85).combined(with: .opacity)) + } + .animation(Motion.focus, value: input.actionCount) + Text("O mapeamento dentro do jogo é do emulador: confira o do PCSX2 em Emuladores e mude no próprio PCSX2.") + .font(Typography.caption) + .foregroundStyle(Theme.textSecondary) + } + } + + static func describe(_ action: InputAction) -> String { + switch action { + case .up: String(localized: "Cima") + case .down: String(localized: "Baixo") + case .left: String(localized: "Esquerda") + case .right: String(localized: "Direita") + case .confirm: String(localized: "Confirmar") + case .back: String(localized: "Voltar") + case .details: String(localized: "Detalhes") + case .favorite: String(localized: "Favoritar") + case .previousShelf: String(localized: "Prateleira anterior") + case .nextShelf: String(localized: "Próxima prateleira") + case .previousTab: String(localized: "Aba anterior") + case .nextTab: String(localized: "Próxima aba") + case .menu: String(localized: "Ajustes") + case .options: String(localized: "Emuladores") + } + } +} diff --git a/Sources/RetroXApp/Input/InputCoordinator.swift b/Sources/RetroXApp/Input/InputCoordinator.swift new file mode 100644 index 0000000..9c8b3d4 --- /dev/null +++ b/Sources/RetroXApp/Input/InputCoordinator.swift @@ -0,0 +1,256 @@ +import GameController +import Observation +import RetroXCore + +/// Ações semânticas da interface. Teclado e controle produzem as mesmas ações (ADR-0007). +enum InputAction: Hashable, Sendable { + case up, down, left, right + case confirm, back, details, favorite + case previousShelf, nextShelf + /// Gatilhos (LT/RT, L2/R2, ZL/ZR) e `[` `]`: trocam a aba de cima. + case previousTab, nextTab + /// Start / Options / +: Ajustes. + case menu + /// View / Create / −: Emuladores. + case options +} + +/// Velocidade da repetição ao segurar uma direção. +enum RepeatSpeed: String, CaseIterable, Identifiable { + case slow, normal, fast + + var id: Self { self } + + var title: String { + switch self { + case .slow: String(localized: "Lenta") + case .normal: String(localized: "Normal") + case .fast: String(localized: "Rápida") + } + } + + var initialDelay: Duration { + switch self { + case .slow: .milliseconds(480) + case .normal: .milliseconds(380) + case .fast: .milliseconds(280) + } + } + + var interval: Duration { + switch self { + case .slow: .milliseconds(140) + case .normal: .milliseconds(90) + case .fast: .milliseconds(55) + } + } +} + +/// Quando mostrar glifos de controle na barra de comandos. +enum HintsMode: String, CaseIterable, Identifiable { + case automatic, keyboard, controller + + var id: Self { self } + + var title: String { + switch self { + case .automatic: String(localized: "Automático") + case .keyboard: String(localized: "Teclado") + case .controller: String(localized: "Controle") + } + } +} + +/// Traduz o controle de jogo em `InputAction`, com repetição ao segurar uma direção. +/// O teclado chega pelas views (`onKeyPress`) e é roteado para o mesmo lugar. +@MainActor +@Observable +final class InputCoordinator { + private(set) var controllerName: String? + /// Família detectada do controle conectado (para o preset automático). + private(set) var detectedPreset: ControllerPreset? + private(set) var preset: ControllerPreset + /// Inverte confirmar e voltar em relação ao preset. + private(set) var swapsConfirmAndBack: Bool + private(set) var repeatSpeed: RepeatSpeed + private(set) var hintsMode: HintsMode + /// Última ação recebida do controle e um contador, para o teste nos Ajustes. + private(set) var lastAction: InputAction? + private(set) var actionCount = 0 + + @ObservationIgnored var onAction: ((InputAction) -> Void)? + @ObservationIgnored private var heldDirection: InputAction? + @ObservationIgnored private var repeatTask: Task? + @ObservationIgnored private var observers: [NSObjectProtocol] = [] + @ObservationIgnored private var controllerGlyphs: [ControllerControl: String] = [:] + @ObservationIgnored private let defaults = UserDefaults.standard + + init() { + preset = UserDefaults.standard.string(forKey: "input.preset").flatMap(ControllerPreset.init(rawValue:)) ?? .automatic + swapsConfirmAndBack = UserDefaults.standard.bool(forKey: "input.swapConfirmBack") + repeatSpeed = UserDefaults.standard.string(forKey: "input.repeatSpeed").flatMap(RepeatSpeed.init(rawValue:)) ?? .normal + hintsMode = UserDefaults.standard.string(forKey: "input.hints").flatMap(HintsMode.init(rawValue:)) ?? .automatic + // Com o emulador na frente, o RetroX não deve receber nada do controle (é o default; fica explícito). + GCController.shouldMonitorBackgroundEvents = false + let center = NotificationCenter.default + // As notificações chegam na fila principal; o controle é relido de lá, sem atravessar isolamento. + observers.append(center.addObserver(forName: .GCControllerDidConnect, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.refreshCurrentController() } + }) + observers.append(center.addObserver(forName: .GCControllerDidDisconnect, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.refreshCurrentController() } + }) + refreshCurrentController() + } + + // MARK: Ajustes + + func setPreset(_ preset: ControllerPreset) { + self.preset = preset + defaults.set(preset.rawValue, forKey: "input.preset") + refreshCurrentController() + } + + func setSwapsConfirmAndBack(_ swaps: Bool) { + swapsConfirmAndBack = swaps + defaults.set(swaps, forKey: "input.swapConfirmBack") + refreshCurrentController() + } + + func setRepeatSpeed(_ speed: RepeatSpeed) { + repeatSpeed = speed + defaults.set(speed.rawValue, forKey: "input.repeatSpeed") + } + + func setHintsMode(_ mode: HintsMode) { + hintsMode = mode + defaults.set(mode.rawValue, forKey: "input.hints") + } + + // MARK: Glifos + + /// O preset que vale agora: o escolhido, ou o detectado no automático. + var effectivePreset: ControllerPreset { + preset == .automatic ? (detectedPreset ?? .xbox) : preset + } + + /// Confirmar fica no botão da direita no layout Nintendo, ou quando o usuário inverte. + var confirmsWithEastButton: Bool { + effectivePreset.confirmsWithEastButton != swapsConfirmAndBack + } + + /// Mostrar glifos de controle (e não teclas) na barra de comandos. + var showsControllerGlyphs: Bool { + switch hintsMode { + case .automatic: controllerName != nil + case .keyboard: false + case .controller: true + } + } + + /// Símbolos SF para cada ação, conforme o preset (ou o próprio controle, no automático). + var glyphs: [InputAction: String] { + guard showsControllerGlyphs else { return [:] } + var result: [InputAction: String] = [:] + for (action, control) in controls { + let symbol = preset == .automatic + ? (controllerGlyphs[control] ?? effectivePreset.glyph(for: control)) + : preset.glyph(for: control) + result[action] = symbol + } + return result + } + + /// Qual controle físico dispara cada ação. + private var controls: [InputAction: ControllerControl] { + [ + .confirm: confirmsWithEastButton ? .east : .south, + .back: confirmsWithEastButton ? .south : .east, + .details: .west, + .favorite: .north, + .previousShelf: .leftShoulder, + .nextShelf: .rightShoulder, + .previousTab: .leftTrigger, + .nextTab: .rightTrigger, + .menu: .menu, + .options: .options, + ] + } + + // MARK: Controle + + private func attach(_ controller: GCController) { + guard let pad = controller.extendedGamepad else { return } + // O GameController entrega os eventos na fila principal (handlerQueue padrão). + pad.dpad.valueChangedHandler = { [weak self] _, x, y in + MainActor.assumeIsolated { self?.hold(Self.direction(x: x, y: y, threshold: 0.5)) } + } + pad.leftThumbstick.valueChangedHandler = { [weak self] _, x, y in + MainActor.assumeIsolated { self?.hold(Self.direction(x: x, y: y, threshold: 0.6)) } + } + let east = confirmsWithEastButton + let buttons: [(GCControllerButtonInput?, ControllerControl, InputAction)] = [ + (pad.buttonA, .south, east ? .back : .confirm), + (pad.buttonB, .east, east ? .confirm : .back), + (pad.buttonX, .west, .details), + (pad.buttonY, .north, .favorite), + (pad.leftShoulder, .leftShoulder, .previousShelf), + (pad.rightShoulder, .rightShoulder, .nextShelf), + (pad.leftTrigger, .leftTrigger, .previousTab), + (pad.rightTrigger, .rightTrigger, .nextTab), + (pad.buttonMenu, .menu, .menu), + (pad.buttonOptions, .options, .options), + ] + for (button, _, action) in buttons { + button?.pressedChangedHandler = { [weak self] _, _, pressed in + guard pressed else { return } + MainActor.assumeIsolated { self?.send(action) } + } + } + controllerName = controller.vendorName ?? String(localized: "Controle") + detectedPreset = ControllerPreset.detect(productCategory: controller.productCategory) + controllerGlyphs = Dictionary(uniqueKeysWithValues: buttons.compactMap { button, control, _ in + button?.sfSymbolsName.map { (control, $0) } + }) + } + + private func refreshCurrentController() { + hold(nil) + if let next = GCController.controllers().first(where: { $0.extendedGamepad != nil }) { + attach(next) + } else { + controllerName = nil + detectedPreset = nil + controllerGlyphs = [:] + } + } + + private func send(_ action: InputAction) { + lastAction = action + actionCount += 1 + onAction?(action) + } + + /// Envia a direção na hora e, se ela continuar segurada, repete depois de um atraso. + private func hold(_ direction: InputAction?) { + guard direction != heldDirection else { return } + heldDirection = direction + repeatTask?.cancel() + guard let direction else { return } + send(direction) + let speed = repeatSpeed + repeatTask = Task { [weak self] in + try? await Task.sleep(for: speed.initialDelay) + while !Task.isCancelled { + self?.send(direction) + try? await Task.sleep(for: speed.interval) + } + } + } + + nonisolated static func direction(x: Float, y: Float, threshold: Float) -> InputAction? { + if abs(x) < threshold, abs(y) < threshold { return nil } + if abs(x) >= abs(y) { return x > 0 ? .right : .left } + return y > 0 ? .up : .down + } +} diff --git a/Sources/RetroXApp/Resources/RayoMark.png b/Sources/RetroXApp/Resources/RayoMark.png new file mode 100644 index 0000000..174f341 Binary files /dev/null and b/Sources/RetroXApp/Resources/RayoMark.png differ diff --git a/Sources/RetroXApp/RetroXApp.swift b/Sources/RetroXApp/RetroXApp.swift new file mode 100644 index 0000000..21b1cdf --- /dev/null +++ b/Sources/RetroXApp/RetroXApp.swift @@ -0,0 +1,140 @@ +import AppKit +import RetroXCore +import SwiftUI + +@main +struct RetroXApp: App { + // Constante em vez de `@State`: no SDK 27 `@State` é uma macro que as Command Line Tools não trazem (ADR-0010). + private let model = AppModel() + + init() { + // Rodando fora de um .app (swift run), o processo nasceria sem ícone no Dock nem foco. + NSApplication.shared.setActivationPolicy(.regular) + } + + var body: some Scene { + Window("RetroX", id: "main") { + RootView() + .environment(model) + .environment(model.covers) + } + .windowStyle(.hiddenTitleBar) + .defaultSize(width: 1240, height: 800) + .commands { + // Os Ajustes ficam dentro da janela, navegáveis pelo controle. + CommandGroup(replacing: .appSettings) { + Button("Ajustes…") { model.navigate(to: .settings) } + .keyboardShortcut(",") + } + CommandMenu("Ir") { + ForEach(Array(AppModel.Screen.tabs.enumerated()), id: \.element) { index, screen in + Button(screen.title) { model.navigate(to: screen) } + .keyboardShortcut(KeyEquivalent(Character("\(index + 1)"))) + } + Divider() + Button("Trocar de perfil") { model.showProfiles() } + .keyboardShortcut("p", modifiers: [.command, .shift]) + } + } + } +} + +struct RootView: View { + @Environment(AppModel.self) private var model + + var body: some View { + ZStack { + Theme.background + .ignoresSafeArea() + if model.screen == .home || model.screen == .library { + AmbientBackdrop() + .transition(.opacity) + } + if model.screen == .profiles { + ProfilePickerView() + .transition(.zoom) + } else { + VStack(spacing: 0) { + TopBar() + ZStack { + screenContent + .id(model.screen) + .transition(.screen(direction: model.navigationDirection)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } + .transition(.zoom) + } + if let game = model.detailsGame { + ModalDim { model.showDetails(nil) } + .transition(.opacity) + .zIndex(10) + GameDetailsView(game: game) + .modalCard() + .transition(.modalCard) + .zIndex(11) + } + if let draft = model.editingProfile { + ModalDim { model.closeEditor() } + .transition(.opacity) + .zIndex(12) + ProfileEditorView(draft: draft) + .modalCard() + .transition(.modalCard) + .zIndex(13) + } + if model.session.isPresenting { + SessionOverlay() + .transition(.opacity) + .zIndex(20) + } + if model.splash != .done { + SplashView() + .transition(.opacity) + .zIndex(30) + } + } + // Tema, fonte e animações formam a identidade da árvore: trocar redesenha tudo com os tokens novos. + .id(model.styleIdentity) + .tint(Theme.accent) + .preferredColorScheme(model.appearance.colorScheme) + .animation(Motion.screen, value: model.screen) + .animation(Motion.screen, value: model.detailsGame?.id) + .animation(Motion.screen, value: model.editingProfile?.id) + .animation(Motion.fade, value: model.session.isPresenting) + .alert(model.notice ?? "", isPresented: Binding(get: { model.notice != nil }, set: { if !$0 { model.notice = nil } })) { + Button("OK", role: .cancel) { model.notice = nil } + } + .frame(minWidth: 1024, minHeight: 680) + .task { await model.start() } + .task { await model.runSplash() } + .task { await DebugSnapshot.captureIfRequested() } + .onChange(of: model.session.phase) { _, phase in + announce(phase) + } + } + + @ViewBuilder + private var screenContent: some View { + switch model.screen { + case .profiles: EmptyView() + case .home: HomeView() + case .library: LibraryView() + case .emulators: EmulatorsView() + case .settings: SettingsScreen() + } + } + + private func announce(_ phase: SessionModel.Phase) { + let message: String? = switch phase { + case .running(let game, let emulator): String(localized: "\(game.title) aberto no \(emulator).") + case .ended: String(localized: "Partida encerrada.") + case .failed(_, let error): error.message + default: nil + } + if let message { + AccessibilityNotification.Announcement(message).post() + } + } +} diff --git a/Sources/RetroXCore/Design/BrandGradient.swift b/Sources/RetroXCore/Design/BrandGradient.swift new file mode 100644 index 0000000..fc9d4ee --- /dev/null +++ b/Sources/RetroXCore/Design/BrandGradient.swift @@ -0,0 +1,61 @@ +import Foundation + +extension HexColor { + public init(red: Double, green: Double, blue: Double) { + self.red = min(max(red, 0), 1) + self.green = min(max(green, 0), 1) + self.blue = min(max(blue, 0), 1) + } + + /// `count` paradas de `from` a `to` misturadas em OKLCH (espaço perceptual), pelo caminho mais curto no + /// círculo de matiz: o meio fica vivo, sem o cinza da mistura em RGB. + public static func oklchStops(from: HexColor, to: HexColor, count: Int = 9) -> [HexColor] { + let a = from.oklch, b = to.oklch + var dh = b.h - a.h + if dh > .pi { dh -= 2 * .pi } else if dh < -.pi { dh += 2 * .pi } + return (0.. Double { c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) } + private static func gamma(_ c: Double) -> Double { + let c = min(max(c, 0), 1) + return c <= 0.0031308 ? 12.92 * c : 1.055 * pow(c, 1 / 2.4) - 0.055 + } + + private var oklch: (l: Double, c: Double, h: Double) { + let r = Self.linear(red), g = Self.linear(green), b = Self.linear(blue) + let l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + let m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + let s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + let L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s + let A = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s + let B = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s + return (L, hypot(A, B), atan2(B, A)) + } + + private init(l: Double, c: Double, h: Double) { + let A = c * cos(h), B = c * sin(h) + let l3 = pow(l + 0.3963377774 * A + 0.2158037573 * B, 3) + let m3 = pow(l - 0.1055613458 * A - 0.0638541728 * B, 3) + let s3 = pow(l - 0.0894841775 * A - 1.2914855480 * B, 3) + self.init( + red: Self.gamma(4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3), + green: Self.gamma(-1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3), + blue: Self.gamma(-0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3) + ) + } +} + +extension Palette { + /// Fim do gradiente do X da marca no escuro: o céu do fundo do tile da Rayo (docs/design/logo). + public static let brandXSky = HexColor("7BADFD") + + /// Paradas do X da marca. No escuro, do destaque do tema até o céu da Rayo; no claro, o próprio destaque, + /// sólido (o gradiente roxo ficava pesado sobre o fundo claro). + public static func brandXStops(_ palette: ThemePalette, dark: Bool) -> [HexColor] { + dark ? HexColor.oklchStops(from: palette.accent, to: brandXSky) : [palette.accent, palette.accent] + } +} diff --git a/Sources/RetroXCore/Design/Palette.swift b/Sources/RetroXCore/Design/Palette.swift new file mode 100644 index 0000000..1e9d11d --- /dev/null +++ b/Sources/RetroXCore/Design/Palette.swift @@ -0,0 +1,91 @@ +import Foundation + +/// Cor sRGB em hexadecimal ("F4F0EA"). Os tokens ficam aqui como dados, sem SwiftUI, para os testes +/// conferirem o contraste (docs/design/brand-spec.md). +public struct HexColor: Sendable, Equatable { + public let red: Double + public let green: Double + public let blue: Double + + public init(_ hex: String) { + let value = UInt32(hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")), radix: 16) ?? 0 + red = Double((value >> 16) & 0xFF) / 255 + green = Double((value >> 8) & 0xFF) / 255 + blue = Double(value & 0xFF) / 255 + } + + /// Luminância relativa (WCAG 2.x). + public var luminance: Double { + func linear(_ channel: Double) -> Double { + channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4) + } + return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue) + } + + public static func contrast(_ first: HexColor, _ second: HexColor) -> Double { + let (lighter, darker) = first.luminance >= second.luminance ? (first, second) : (second, first) + return (lighter.luminance + 0.05) / (darker.luminance + 0.05) + } +} + +/// Par claro/escuro de um token. +public struct Tone: Sendable, Equatable { + public let light: HexColor + public let dark: HexColor + + public init(light: String, dark: String) { + self.light = HexColor(light) + self.dark = HexColor(dark) + } +} + +/// Paleta "Portátil quente": neutros quentes (papel de dia, grafite à noite) e um único acento. +public enum Palette { + public static let background = Tone(light: "F4F0EA", dark: "1B1916") + public static let surface = Tone(light: "FFFDF9", dark: "262320") + public static let raised = Tone(light: "EAE4DB", dark: "312D29") + public static let textPrimary = Tone(light: "1E1B18", dark: "F3EEE7") + public static let textSecondary = Tone(light: "6A625A", dark: "ADA59B") + public static let divider = Tone(light: "DDD5CA", dark: "3B3632") + + /// Cor de tema escolhida pelo usuário. Só uma por vez; nunca em texto pequeno, só em preenchimentos, + /// anéis de foco e títulos grandes. + public enum Accent: String, CaseIterable, Codable, Sendable { + case coral, ambar, salvia, ardosia + + public var fill: Tone { + switch self { + case .coral: Tone(light: "C7472A", dark: "F07A57") + case .ambar: Tone(light: "A85E12", dark: "E9A23B") + case .salvia: Tone(light: "4F7A52", dark: "8DBB8F") + case .ardosia: Tone(light: "44657F", dark: "8DB3CF") + } + } + + /// Texto e ícones sobre o preenchimento do acento. + public var onFill: Tone { Tone(light: "FFFDF9", dark: "1B1916") } + + public var title: String { + switch self { + case .coral: String(localized: "Coral") + case .ambar: String(localized: "Âmbar") + case .salvia: String(localized: "Sálvia") + case .ardosia: String(localized: "Ardósia") + } + } + } + + /// Tom das capas geradas quando não há imagem, por sistema. Não usa cores de marca. + public static func coverTone(for system: GameSystemID) -> Tone { + let hex = coverTones[system] ?? "3A3530" + return Tone(light: hex, dark: hex) + } + + /// Tons escuros e dessaturados, um por sistema, para distinguir capas geradas sem imitar cores de marca. + private static let coverTones: [GameSystemID: String] = [ + .ps1: "4A4440", .ps2: "2E3742", .ps3: "2A2D33", .psp: "3A3550", .gamecube: "3F3A5A", .wii: "4A5560", + .nds: "3F4A45", .n3ds: "5A3A3A", .switchConsole: "5A3434", .gba: "3E3A5E", .snes: "46424F", .n64: "2F4A3F", + ] + + public static let onCover = Tone(light: "F3EEE7", dark: "F3EEE7") +} diff --git a/Sources/RetroXCore/Design/Themes.swift b/Sources/RetroXCore/Design/Themes.swift new file mode 100644 index 0000000..67f9eed --- /dev/null +++ b/Sources/RetroXCore/Design/Themes.swift @@ -0,0 +1,139 @@ +import Foundation + +/// Um conjunto completo de cores para um modo (claro ou escuro). +public struct ThemePalette: Sendable, Equatable { + public let background: HexColor + public let surface: HexColor + public let raised: HexColor + public let textPrimary: HexColor + public let textSecondary: HexColor + public let divider: HexColor + public let accent: HexColor + public let onAccent: HexColor + + /// Ordem: fundo, superfície, elevado, texto, texto secundário, divisória, acento, texto sobre o acento. + init(_ hex: [String]) { + precondition(hex.count == 8) + background = HexColor(hex[0]) + surface = HexColor(hex[1]) + raised = HexColor(hex[2]) + textPrimary = HexColor(hex[3]) + textSecondary = HexColor(hex[4]) + divider = HexColor(hex[5]) + accent = HexColor(hex[6]) + onAccent = HexColor(hex[7]) + } +} + +public enum AppearancePreference: String, Codable, CaseIterable, Sendable, Identifiable { + case system, light, dark + + public var id: Self { self } + + public var title: String { + switch self { + case .system: String(localized: "Sistema") + case .light: String(localized: "Claro") + case .dark: String(localized: "Escuro") + } + } +} + +/// Temas de cor. RetroX e Rayo Paradise são próprios; os esquemas conhecidos usam as cores oficiais de cada projeto, com ajustes mínimos +/// onde a cor original não atingia o contraste AA (texto ≥ 4,5:1, anel de foco ≥ 3:1). `PaletteTests` confere. +public enum ThemeID: String, CaseIterable, Codable, Sendable, Identifiable { + case retrox, rayoParadise, gruvbox, catppuccin, tokyoNight, nord, solarized, dracula, rosePine, everforest, kanagawa, one + + public var id: Self { self } + + public var title: String { + switch self { + case .retrox: "RetroX" + case .rayoParadise: "Rayo Paradise" + case .gruvbox: "Gruvbox" + case .catppuccin: "Catppuccin" + case .tokyoNight: "Tokyo Night" + case .nord: "Nord" + case .solarized: "Solarized" + case .dracula: "Dracula" + case .rosePine: "Rosé Pine" + case .everforest: "Everforest" + case .kanagawa: "Kanagawa" + case .one: "One" + } + } + + /// Nome de cada variante, como o próprio esquema chama. + public func variantName(dark: Bool) -> String { + switch self { + case .catppuccin: dark ? "Mocha" : "Latte" + case .tokyoNight: dark ? "Night" : "Day" + case .rosePine: dark ? "Main" : "Dawn" + case .kanagawa: dark ? "Wave" : "Lotus" + case .dracula: dark ? "Dracula" : "Alucard" + default: dark ? String(localized: "Escuro") : String(localized: "Claro") + } + } + + /// `retroXAccent` só vale para o tema RetroX; os outros esquemas têm acento próprio. + public func palette(dark: Bool, retroXAccent: Palette.Accent = .coral) -> ThemePalette { + switch self { + case .retrox: + let pick = { (tone: Tone) in Self.hex(dark ? tone.dark : tone.light) } + return ThemePalette([ + pick(Palette.background), pick(Palette.surface), pick(Palette.raised), pick(Palette.textPrimary), + pick(Palette.textSecondary), pick(Palette.divider), pick(retroXAccent.fill), pick(retroXAccent.onFill), + ]) + case .rayoParadise: + // Cores da Rayo (docs/design/logo/README.md): noite em índigo com o rosa do cabelo; dia no céu do + // fundo do tile com a framboesa do X. + return dark + ? ThemePalette(["191A2E", "22233B", "2C2D4A", "F6ECF0", "B9B6DA", "3A3B5C", "FEB5B8", "191A2E"]) + : ThemePalette(["EDF3FF", "F8FAFF", "DDE6FA", "1E1E3A", "4E5175", "CDD8F0", "D63A62", "FFFFFF"]) + case .gruvbox: + return dark + ? ThemePalette(["282828", "32302F", "3C3836", "EBDBB2", "BDAE93", "504945", "FE8019", "282828"]) + : ThemePalette(["FBF1C7", "F9F5D7", "EBDBB2", "3C3836", "665C54", "D5C4A1", "AF3A03", "FBF1C7"]) + case .catppuccin: + return dark + ? ThemePalette(["1E1E2E", "313244", "45475A", "CDD6F4", "B2B8D0", "585B70", "CBA6F7", "1E1E2E"]) + : ThemePalette(["E6E9EF", "EFF1F5", "CCD0DA", "3A3D56", "545768", "BCC0CC", "8839EF", "EFF1F5"]) + case .tokyoNight: + return dark + ? ThemePalette(["1A1B26", "24283B", "292E42", "C0CAF5", "A9B1D6", "3B4261", "7AA2F7", "1A1B26"]) + : ThemePalette(["E1E2E7", "EEEFF3", "D0D5E3", "2A4A94", "4C5989", "C4C8DA", "2A73D6", "FFFFFF"]) + case .nord: + return dark + ? ThemePalette(["2E3440", "3B4252", "434C5E", "ECEFF4", "D8DEE9", "4C566A", "88C0D0", "2E3440"]) + : ThemePalette(["E5E9F0", "ECEFF4", "D8DEE9", "2E3440", "4C566A", "C9D0DC", "4F6C90", "ECEFF4"]) + case .solarized: + return dark + ? ThemePalette(["002B36", "073642", "0B404D", "EEE8D5", "9CA9AB", "1E4C57", "B58900", "002B36"]) + : ThemePalette(["FDF6E3", "FFFBEF", "EEE8D5", "073642", "576A71", "E2DBC5", "BF4615", "FDF6E3"]) + case .dracula: + return dark + ? ThemePalette(["282A36", "343746", "44475A", "F8F8F2", "BFC3D9", "4D5066", "BD93F9", "282A36"]) + : ThemePalette(["FFFBEB", "FFFFFF", "EFEDDC", "1F1F1F", "635D97", "DCD9C6", "644AC9", "FFFBEB"]) + case .rosePine: + return dark + ? ThemePalette(["191724", "1F1D2E", "26233A", "E0DEF4", "908CAA", "403D52", "EBBCBA", "191724"]) + : ThemePalette(["FAF4ED", "FFFAF3", "F2E9E1", "575279", "68657E", "DFDAD9", "286983", "FAF4ED"]) + case .everforest: + return dark + ? ThemePalette(["2D353B", "343F44", "3D484D", "D3C6AA", "AFB8B1", "475258", "A7C080", "2D353B"]) + : ThemePalette(["F4F0D9", "FDF6E3", "E6E2CC", "3C474D", "57646B", "E0DCC7", "687701", "FDF6E3"]) + case .kanagawa: + return dark + ? ThemePalette(["1F1F28", "2A2A37", "363646", "DCD7BA", "C8C093", "54546D", "7E9CD8", "1F1F28"]) + : ThemePalette(["F2ECBC", "F5F0C8", "E5DDB0", "545464", "5D5D6C", "D5CEA3", "4B6798", "F2ECBC"]) + case .one: + return dark + ? ThemePalette(["282C34", "2C313A", "3E4451", "DCDFE4", "ADB4C0", "4B5263", "61AFEF", "282C34"]) + : ThemePalette(["FAFAFA", "FFFFFF", "EAEAEB", "383A42", "656872", "DBDBDC", "3B6EDF", "FFFFFF"]) + } + } + + private static func hex(_ color: HexColor) -> String { + String(format: "%02X%02X%02X", Int((color.red * 255).rounded()), Int((color.green * 255).rounded()), Int((color.blue * 255).rounded())) + } +} diff --git a/Sources/RetroXCore/Domain/GameSystem.swift b/Sources/RetroXCore/Domain/GameSystem.swift new file mode 100644 index 0000000..15c0782 --- /dev/null +++ b/Sources/RetroXCore/Domain/GameSystem.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Identificador estável de um sistema (console). O valor bruto é persistido: não mude slugs existentes. +public struct GameSystemID: RawRepresentable, Hashable, Codable, Sendable, CustomStringConvertible { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public var description: String { rawValue } + + public static let ps1 = GameSystemID(rawValue: "ps1") + public static let ps2 = GameSystemID(rawValue: "ps2") + public static let ps3 = GameSystemID(rawValue: "ps3") + public static let psp = GameSystemID(rawValue: "psp") + public static let gamecube = GameSystemID(rawValue: "gamecube") + public static let wii = GameSystemID(rawValue: "wii") + public static let nds = GameSystemID(rawValue: "nds") + public static let n3ds = GameSystemID(rawValue: "3ds") + public static let gba = GameSystemID(rawValue: "gba") + public static let snes = GameSystemID(rawValue: "snes") + public static let n64 = GameSystemID(rawValue: "n64") + public static let switchConsole = GameSystemID(rawValue: "switch") +} + +public struct GameSystem: Hashable, Sendable, Identifiable { + public let id: GameSystemID + public let displayName: String + public let shortName: String + /// Formatos comuns do sistema (minúsculas, sem ponto). O que é indexado de fato depende do plugin ativo. + public let fileExtensions: Set +} + +/// Catálogo de sistemas definido em código e versionado com o app (PRD §12). Estar no catálogo não +/// significa ter emulador: hoje só o PS2 tem plugin (PCSX2). Os outros aparecem no filtro de console. +public enum SystemCatalog { + public static let ps2 = GameSystem(id: .ps2, displayName: "PlayStation 2", shortName: "PS2", fileExtensions: ["iso", "chd", "cso", "zso"]) + + public static let all: [GameSystem] = [ + GameSystem(id: .ps1, displayName: "PlayStation", shortName: "PS1", fileExtensions: ["cue", "chd", "pbp", "m3u"]), + ps2, + GameSystem(id: .ps3, displayName: "PlayStation 3", shortName: "PS3", fileExtensions: ["iso", "pkg"]), + GameSystem(id: .psp, displayName: "PSP", shortName: "PSP", fileExtensions: ["iso", "cso", "chd"]), + GameSystem(id: .gamecube, displayName: "GameCube", shortName: "GC", fileExtensions: ["iso", "gcm", "rvz", "ciso", "gcz"]), + GameSystem(id: .wii, displayName: "Wii", shortName: "Wii", fileExtensions: ["iso", "wbfs", "rvz", "wia"]), + GameSystem(id: .nds, displayName: "Nintendo DS", shortName: "NDS", fileExtensions: ["nds"]), + GameSystem(id: .n3ds, displayName: "Nintendo 3DS", shortName: "3DS", fileExtensions: ["3ds", "cci", "cia"]), + GameSystem(id: .switchConsole, displayName: "Nintendo Switch", shortName: "Switch", fileExtensions: ["nsp", "xci"]), + GameSystem(id: .gba, displayName: "Game Boy Advance", shortName: "GBA", fileExtensions: ["gba"]), + GameSystem(id: .snes, displayName: "Super Nintendo", shortName: "SNES", fileExtensions: ["sfc", "smc"]), + GameSystem(id: .n64, displayName: "Nintendo 64", shortName: "N64", fileExtensions: ["n64", "z64", "v64"]), + ] + + public static func system(_ id: GameSystemID) -> GameSystem? { + all.first { $0.id == id } + } +} diff --git a/Sources/RetroXCore/Domain/TitleParser.swift b/Sources/RetroXCore/Domain/TitleParser.swift new file mode 100644 index 0000000..9224fff --- /dev/null +++ b/Sources/RetroXCore/Domain/TitleParser.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum TitleParser { + /// Título de exibição derivado do nome do arquivo: sem extensão, `_` vira espaço, espaços colapsados e Unicode em NFC. + /// Marcas como região, entre parênteses, são mantidas. + public static func displayTitle(fromFilename filename: String) -> String { + let base = (filename as NSString).deletingPathExtension + let words = base.replacingOccurrences(of: "_", with: " ").split(whereSeparator: \.isWhitespace) + let title = words.joined(separator: " ").precomposedStringWithCanonicalMapping + return title.isEmpty ? filename.precomposedStringWithCanonicalMapping : title + } + + /// Título para buscar em fontes externas: sem região, tags e versões. "GT 4 Spec II v1.08" → "GT 4 Spec II". + public static func searchTitle(_ title: String) -> String { + let withoutGroups = title + .replacingOccurrences(of: "\\([^)]*\\)|\\[[^\\]]*\\]", with: " ", options: .regularExpression) + let words = withoutGroups.split(whereSeparator: \.isWhitespace).filter { + $0.range(of: "^v\\d+(\\.\\d+)*$", options: [.regularExpression, .caseInsensitive]) == nil + } + return words.joined(separator: " ") + } + + /// Chave de busca que ignora caixa, acentos e largura: "Ação" e "acao" geram a mesma chave. + public static func searchKey(_ text: String) -> String { + text.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: nil) + .precomposedStringWithCanonicalMapping + } +} diff --git a/Sources/RetroXCore/Emulators/AppBundleInspector.swift b/Sources/RetroXCore/Emulators/AppBundleInspector.swift new file mode 100644 index 0000000..cf9a658 --- /dev/null +++ b/Sources/RetroXCore/Emulators/AppBundleInspector.swift @@ -0,0 +1,49 @@ +import Foundation + +public struct AppBundleInfo: Sendable, Equatable { + public let bundleURL: URL + public let executableURL: URL + public let bundleIdentifier: String? + public let version: String? + public let architectures: Set +} + +/// Lê o Info.plist e o cabeçalho do executável de um `.app` sem carregá-lo (`Bundle(url:)` guardaria cache de versões antigas). +public struct AppBundleInspector: Sendable { + private let fileSystem: any FileSystemProbing + + public init(fileSystem: any FileSystemProbing = LocalFileSystem()) { + self.fileSystem = fileSystem + } + + public func inspect(_ bundleURL: URL) throws(LaunchError) -> AppBundleInfo { + switch fileSystem.kind(of: bundleURL) { + case .missing: throw .emulatorNotFound + case .regularFile: throw .emulatorBundleInvalid + case .directory: break + } + + let contentsURL = bundleURL.appending(path: "Contents", directoryHint: .isDirectory) + let plistURL = contentsURL.appending(path: "Info.plist", directoryHint: .notDirectory) + guard let plistData = try? fileSystem.readPrefix(of: plistURL, maxLength: 4 << 20), + let plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any], + let executableName = plist["CFBundleExecutable"] as? String, + !executableName.isEmpty, !executableName.contains("/") + else { throw .emulatorBundleInvalid } + + let executableURL = contentsURL + .appending(path: "MacOS", directoryHint: .isDirectory) + .appending(path: executableName, directoryHint: .notDirectory) + guard fileSystem.kind(of: executableURL) == .regularFile else { throw .emulatorBundleInvalid } + guard fileSystem.isExecutable(executableURL) else { throw .emulatorNotExecutable } + + let header = (try? fileSystem.readPrefix(of: executableURL, maxLength: 4096)) ?? Data() + return AppBundleInfo( + bundleURL: bundleURL, + executableURL: executableURL, + bundleIdentifier: plist["CFBundleIdentifier"] as? String, + version: (plist["CFBundleShortVersionString"] ?? plist["CFBundleVersion"]) as? String, + architectures: MachOHeader.architectures(in: header) + ) + } +} diff --git a/Sources/RetroXCore/Emulators/EmulatorAdapter.swift b/Sources/RetroXCore/Emulators/EmulatorAdapter.swift new file mode 100644 index 0000000..8791400 --- /dev/null +++ b/Sources/RetroXCore/Emulators/EmulatorAdapter.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Identificador estável de um adapter. O valor bruto é persistido nos perfis. +public struct EmulatorAdapterID: RawRepresentable, Hashable, Codable, Sendable, CustomStringConvertible { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public var description: String { rawValue } + + public static let pcsx2 = EmulatorAdapterID(rawValue: "pcsx2") +} + +/// Opções tipadas por emulador. Um caso por adapter, para o compilador cobrar cada novo emulador. +public enum EmulatorOptions: Codable, Sendable, Equatable { + case pcsx2(PCSX2Options) +} + +/// Conhecimento específico de um emulador: o que ele abre e como montar seus argumentos. +/// O adapter não executa nada; o `GameLauncher` valida e lança (ADR-0003). +public protocol EmulatorAdapter: Sendable { + var id: EmulatorAdapterID { get } + var displayName: String { get } + var expectedBundleIdentifiers: Set { get } + var supportedSystems: Set { get } + var defaultStrategy: LaunchStrategy { get } + var defaultOptions: EmulatorOptions { get } + + /// Extensões (minúsculas, sem ponto) que este emulador abre para o sistema. + func supportedExtensions(for system: GameSystemID) -> Set + + /// argv sem o executável. O caminho da ROM é sempre um único elemento: nada passa por shell. + func arguments(romPath: String, options: EmulatorOptions, extraArguments: [String]) throws(LaunchError) -> [String] +} + +public struct EmulatorRegistry: Sendable { + private let adapters: [EmulatorAdapterID: any EmulatorAdapter] + + public init(_ adapters: [any EmulatorAdapter]) { + self.adapters = Dictionary(adapters.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + } + + public static let standard = EmulatorRegistry([PCSX2Adapter()]) + + public func adapter(for id: EmulatorAdapterID) -> (any EmulatorAdapter)? { + adapters[id] + } + + public func adapters(for system: GameSystemID) -> [any EmulatorAdapter] { + adapters.values.filter { $0.supportedSystems.contains(system) }.sorted { $0.displayName < $1.displayName } + } +} + +enum ExtraArguments { + /// Recusa argumentos vazios, com NUL, ou que colidem com as flags controladas pelo adapter + /// (incluindo a forma `--flag=valor`). + static func validate(_ arguments: [String], managedFlags: Set) throws(LaunchError) { + for argument in arguments { + let flag = argument.split(separator: "=", maxSplits: 1).first.map(String.init) ?? argument + if argument.isEmpty || argument.contains("\0") || managedFlags.contains(flag) { + throw .invalidExtraArgument(argument) + } + } + } +} diff --git a/Sources/RetroXCore/Emulators/HostEnvironment.swift b/Sources/RetroXCore/Emulators/HostEnvironment.swift new file mode 100644 index 0000000..772a05c --- /dev/null +++ b/Sources/RetroXCore/Emulators/HostEnvironment.swift @@ -0,0 +1,43 @@ +import Foundation + +/// O que o Mac atual consegue executar. Valor simples para os testes simularem outros hosts. +public struct HostEnvironment: Sendable, Equatable { + public enum Compatibility: Sendable, Equatable { + case native + case rosetta + /// O executável não é Mach-O reconhecível (por exemplo, um script); não há como checar. + case unknown + case incompatible(rosettaMissing: Bool) + } + + /// Arquitetura do hardware (arm64 mesmo se o RetroX estiver rodando via Rosetta). + public var architecture: CPUArchitecture + public var isRosettaInstalled: Bool + + public init(architecture: CPUArchitecture, isRosettaInstalled: Bool) { + self.architecture = architecture + self.isRosettaInstalled = isRosettaInstalled + } + + /// A presença do runtime do Rosetta é uma heurística: não há API pública para isso. + static let rosettaRuntimeURL = URL(filePath: "/Library/Apple/usr/libexec/oah/libRosettaRuntime") + + public static func current(fileSystem: any FileSystemProbing = LocalFileSystem()) -> HostEnvironment { + var isAppleSilicon: Int32 = 0 + var size = MemoryLayout.size + let hasARM64 = sysctlbyname("hw.optional.arm64", &isAppleSilicon, &size, nil, 0) == 0 && isAppleSilicon == 1 + return HostEnvironment( + architecture: hasARM64 ? .arm64 : .x86_64, + isRosettaInstalled: fileSystem.kind(of: rosettaRuntimeURL) == .regularFile + ) + } + + public func compatibility(with architectures: Set) -> Compatibility { + if architectures.isEmpty { return .unknown } + if architectures.contains(architecture) { return .native } + if architecture == .arm64, architectures.contains(.x86_64) { + return isRosettaInstalled ? .rosetta : .incompatible(rosettaMissing: true) + } + return .incompatible(rosettaMissing: false) + } +} diff --git a/Sources/RetroXCore/Emulators/MachOHeader.swift b/Sources/RetroXCore/Emulators/MachOHeader.swift new file mode 100644 index 0000000..21a1437 --- /dev/null +++ b/Sources/RetroXCore/Emulators/MachOHeader.swift @@ -0,0 +1,62 @@ +import Foundation + +public enum CPUArchitecture: Hashable, Sendable, CustomStringConvertible { + case arm64 + case x86_64 + case other(cpuType: Int32) + + init(cpuType: Int32) { + switch cpuType { + case 0x0100_000C: self = .arm64 // CPU_TYPE_ARM64 + case 0x0100_0007: self = .x86_64 // CPU_TYPE_X86_64 + default: self = .other(cpuType: cpuType) + } + } + + public var description: String { + switch self { + case .arm64: "arm64" + case .x86_64: "x86_64" + case .other(let cpuType): "cpu(0x\(String(cpuType, radix: 16)))" + } + } +} + +enum MachOHeader { + /// Arquiteturas de um executável Mach-O, thin ou universal, a partir dos primeiros bytes do arquivo. + /// Retorna um conjunto vazio quando o formato não é reconhecido (por exemplo, um script). + static func architectures(in header: Data) -> Set { + guard let magic = header.uint32(at: 0, bigEndian: true) else { return [] } + switch magic { + case 0xCFFA_EDFE, 0xCEFA_EDFE: + // Thin little-endian (64 ou 32 bits): cputype logo após o magic. + guard let cpuType = header.uint32(at: 4, bigEndian: false) else { return [] } + return [CPUArchitecture(cpuType: Int32(bitPattern: cpuType))] + case 0xCAFE_BABE, 0xCAFE_BABF: + // Universal: cabeçalhos big-endian de 20 bytes (fat) ou 32 bytes (fat64). + let entrySize = magic == 0xCAFE_BABE ? 20 : 32 + // Arquivos .class de Java também começam com CAFEBABE, mas com uma "contagem" bem maior. + guard let count = header.uint32(at: 4, bigEndian: true), (1...32).contains(count) else { return [] } + var result: Set = [] + for index in 0.. UInt32? { + guard offset >= 0, offset + 4 <= count else { return nil } + var value: UInt32 = 0 + for byte in 0..<4 { + value = value << 8 | UInt32(self[startIndex + offset + (bigEndian ? byte : 3 - byte)]) + } + return value + } +} diff --git a/Sources/RetroXCore/Emulators/PCSX2Adapter.swift b/Sources/RetroXCore/Emulators/PCSX2Adapter.swift new file mode 100644 index 0000000..15f7ece --- /dev/null +++ b/Sources/RetroXCore/Emulators/PCSX2Adapter.swift @@ -0,0 +1,78 @@ +import Foundation + +public struct PCSX2Options: Codable, Sendable, Equatable { + public enum Fullscreen: String, Codable, Sendable, CaseIterable { + /// Não passa flag: vale a configuração do próprio PCSX2. + case emulatorDefault + case on + case off + } + + public enum Boot: String, Codable, Sendable, CaseIterable { + case emulatorDefault + case fast + case slow + } + + /// `-batch`: o PCSX2 fecha quando o jogo para, e o RetroX volta à frente. + public var batch: Bool + public var fullscreen: Fullscreen + public var bigPicture: Bool + public var boot: Boot + + public init(batch: Bool = true, fullscreen: Fullscreen = .emulatorDefault, bigPicture: Bool = false, boot: Boot = .emulatorDefault) { + self.batch = batch + self.fullscreen = fullscreen + self.bigPicture = bigPicture + self.boot = boot + } +} + +/// Flags conferidas no texto de ajuda do binário macOS do PCSX2 2.9.144: +/// `Usage: {} [parameters] [--] [boot filename]` (ver docs/spikes/0001-launch-pcsx2-macos.md). +public struct PCSX2Adapter: EmulatorAdapter { + public let id = EmulatorAdapterID.pcsx2 + public let displayName = "PCSX2" + public let expectedBundleIdentifiers: Set = ["net.pcsx2.pcsx2"] + public let supportedSystems: Set = [.ps2] + public let defaultStrategy = LaunchStrategy.workspace + public var defaultOptions: EmulatorOptions { .pcsx2(PCSX2Options()) } + + /// Flags que o RetroX gera, que trocariam o que é iniciado, ou que fazem o PCSX2 sair na hora. + static let managedFlags: Set = [ + "--", "-batch", "-nogui", "-fullscreen", "-nofullscreen", "-bigpicture", "-fastboot", "-slowboot", + "-elf", "-disc", "-bios", "-help", "-version", "-testconfig", + ] + + public init() {} + + public func supportedExtensions(for system: GameSystemID) -> Set { + system == .ps2 ? ["iso", "chd", "cso", "zso"] : [] + } + + public func arguments(romPath: String, options: EmulatorOptions, extraArguments: [String]) throws(LaunchError) -> [String] { + let pcsx2: PCSX2Options + switch options { + case .pcsx2(let given): pcsx2 = given + } + try ExtraArguments.validate(extraArguments, managedFlags: Self.managedFlags) + + var arguments: [String] = [] + if pcsx2.batch { arguments.append("-batch") } + switch pcsx2.fullscreen { + case .emulatorDefault: break + case .on: arguments.append("-fullscreen") + case .off: arguments.append("-nofullscreen") + } + if pcsx2.bigPicture { arguments.append("-bigpicture") } + switch pcsx2.boot { + case .emulatorDefault: break + case .fast: arguments.append("-fastboot") + case .slow: arguments.append("-slowboot") + } + arguments += extraArguments + // `--` encerra as opções: nomes com espaços ou iniciados por hífen chegam inteiros. + arguments += ["--", romPath] + return arguments + } +} diff --git a/Sources/RetroXCore/Input/ControllerPreset.swift b/Sources/RetroXCore/Input/ControllerPreset.swift new file mode 100644 index 0000000..874b001 --- /dev/null +++ b/Sources/RetroXCore/Input/ControllerPreset.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Controles físicos, por posição (o GameController nomeia os botões pela posição do layout Xbox). +public enum ControllerControl: String, CaseIterable, Sendable { + case south, east, west, north + case leftShoulder, rightShoulder, leftTrigger, rightTrigger + case menu, options +} + +/// Como mostrar e interpretar os botões: glifos de cada família de controle e qual botão confirma. +public enum ControllerPreset: String, Codable, CaseIterable, Sendable, Identifiable { + /// Usa os glifos do próprio controle conectado; confirma com o botão de baixo. + case automatic + case xbox + /// DualSense e DualShock. + case playStation + /// Switch Pro e Joy-Con: confirma com o botão da direita (A). + case nintendo + + public var id: Self { self } + + public var title: String { + switch self { + case .automatic: String(localized: "Automático") + case .xbox: "Xbox" + case .playStation: "PlayStation (DualSense)" + case .nintendo: "Nintendo Switch" + } + } + + /// No layout Nintendo, A fica à direita e é o botão de confirmar. + public var confirmsWithEastButton: Bool { + self == .nintendo + } + + /// Símbolo SF do botão nesta família; `nil` no automático (usa o do controle conectado). + public func glyph(for control: ControllerControl) -> String? { + switch self { + case .automatic: + return nil + case .xbox: + return [ + .south: "a.circle", .east: "b.circle", .west: "x.circle", .north: "y.circle", + .leftShoulder: "lb.rectangle.roundedbottom", .rightShoulder: "rb.rectangle.roundedbottom", + .leftTrigger: "lt.rectangle.roundedtop", .rightTrigger: "rt.rectangle.roundedtop", + .menu: "line.3.horizontal.circle", .options: "rectangle.on.rectangle.circle", + ][control] + case .playStation: + return [ + .south: "xmark.circle", .east: "circle.circle", .west: "square.circle", .north: "triangle.circle", + .leftShoulder: "l1.rectangle.roundedbottom", .rightShoulder: "r1.rectangle.roundedbottom", + .leftTrigger: "l2.rectangle.roundedtop", .rightTrigger: "r2.rectangle.roundedtop", + .menu: "line.3.horizontal.circle", .options: "rectangle.on.rectangle.circle", + ][control] + case .nintendo: + return [ + .south: "b.circle", .east: "a.circle", .west: "y.circle", .north: "x.circle", + .leftShoulder: "l.rectangle.roundedbottom", .rightShoulder: "r.rectangle.roundedbottom", + .leftTrigger: "zl.rectangle.roundedtop", .rightTrigger: "zr.rectangle.roundedtop", + .menu: "plus.circle", .options: "minus.circle", + ][control] + } + } + + /// Família do controle conectado, pela categoria que o GameController informa ("DualSense", "Xbox One"…). + public static func detect(productCategory: String) -> ControllerPreset { + let category = productCategory.lowercased() + if category.contains("dualsense") || category.contains("dualshock") || category.contains("playstation") { + return .playStation + } + if category.contains("switch") || category.contains("joy-con") || category.contains("nintendo") { + return .nintendo + } + return .xbox + } +} diff --git a/Sources/RetroXCore/Launching/FoundationProcessLauncher.swift b/Sources/RetroXCore/Launching/FoundationProcessLauncher.swift new file mode 100644 index 0000000..bc74b95 --- /dev/null +++ b/Sources/RetroXCore/Launching/FoundationProcessLauncher.swift @@ -0,0 +1,56 @@ +import AppKit + +/// Executa o binário de dentro do `.app` diretamente, com argv em array (sem shell). +/// O emulador vira filho do RetroX: dá exit status, mas prompts de privacidade passam a ser atribuídos ao RetroX. +public struct FoundationProcessLauncher: ProcessLaunching { + /// Destino de stdout/stderr do emulador. `nil` descarta: um pipe sem leitor travaria ou mataria o emulador. + public var outputLogURL: URL? + + public init(outputLogURL: URL? = nil) { + self.outputLogURL = outputLogURL + } + + public func launch(_ plan: LaunchPlan) async throws(LaunchError) -> LaunchedProcess { + let process = Process() + process.executableURL = plan.executableURL + process.arguments = plan.arguments + process.standardInput = FileHandle.nullDevice + let output = openOutput() + process.standardOutput = output + process.standardError = output + + let (exits, continuation) = AsyncStream.makeStream(of: ProcessExit.self) + process.terminationHandler = { finished in + continuation.yield(ProcessExit(status: finished.terminationStatus, endedAt: Date())) + continuation.finish() + } + do { + try process.run() + } catch { + throw .launchFailed(error.localizedDescription) + } + + let pid = process.processIdentifier + if plan.activates { + await Self.activateWhenRegistered(pid: pid) + } + return LaunchedProcess(pid: pid, launchedAt: Date(), plan: plan, exits: exits) + } + + private func openOutput() -> FileHandle { + guard let outputLogURL else { return FileHandle.nullDevice } + FileManager.default.createFile(atPath: outputLogURL.path(percentEncoded: false), contents: nil) + return (try? FileHandle(forWritingTo: outputLogURL)) ?? FileHandle.nullDevice + } + + /// Um processo iniciado por exec não é ativado pelo sistema; o app só aparece para o AppKit depois de registrar. + private static func activateWhenRegistered(pid: pid_t) async { + for _ in 0..<20 { + if let app = NSRunningApplication(processIdentifier: pid) { + if app.activationPolicy == .regular { app.activate() } + return + } + try? await Task.sleep(for: .milliseconds(150)) + } + } +} diff --git a/Sources/RetroXCore/Launching/GameLauncher.swift b/Sources/RetroXCore/Launching/GameLauncher.swift new file mode 100644 index 0000000..0f4a02a --- /dev/null +++ b/Sources/RetroXCore/Launching/GameLauncher.swift @@ -0,0 +1,102 @@ +import Foundation + +/// Valida ROM e emulador, monta o plano e o executa (ADR-0003). +public struct GameLauncher: Sendable { + private let registry: EmulatorRegistry + private let fileSystem: any FileSystemProbing + private let host: HostEnvironment + private let runningApplications: any RunningApplicationsProviding + private let launchers: [LaunchStrategy: any ProcessLaunching] + + public init( + registry: EmulatorRegistry = .standard, + fileSystem: any FileSystemProbing = LocalFileSystem(), + host: HostEnvironment = .current(), + runningApplications: any RunningApplicationsProviding = WorkspaceRunningApplications(), + launchers: [LaunchStrategy: any ProcessLaunching] = [.workspace: WorkspaceLauncher(), .process: FoundationProcessLauncher()] + ) { + self.registry = registry + self.fileSystem = fileSystem + self.host = host + self.runningApplications = runningApplications + self.launchers = launchers + } + + /// Valida tudo e monta o plano, sem executar nada. É também o que o modo simulado mostra. + public func prepare(_ request: LaunchRequest) throws(LaunchError) -> PreparedLaunch { + let profile = request.profile + guard let adapter = registry.adapter(for: profile.adapterID) else { + throw .emulatorNotConfigured(request.systemID) + } + guard adapter.supportedSystems.contains(request.systemID) else { + throw .emulatorDoesNotSupportSystem(request.systemID) + } + try validateROM(request.romURL, supportedExtensions: adapter.supportedExtensions(for: request.systemID)) + + let app = try AppBundleInspector(fileSystem: fileSystem).inspect(profile.appURL) + var warnings: [LaunchWarning] = [] + if !(app.bundleIdentifier.map(adapter.expectedBundleIdentifiers.contains) ?? false) { + warnings.append(.unexpectedBundleIdentifier(app.bundleIdentifier)) + } + switch host.compatibility(with: app.architectures) { + case .native: break + case .rosetta: warnings.append(.runsUnderRosetta) + case .unknown: warnings.append(.unknownArchitecture) + case .incompatible(let rosettaMissing): throw .architectureUnsupported(rosettaMissing: rosettaMissing) + } + + let createsNewInstance = request.alreadyRunningPolicy == .launchNewInstance + if !createsNewInstance, let bundleID = app.bundleIdentifier, runningApplications.isRunning(bundleIdentifier: bundleID) { + throw .emulatorAlreadyRunning(bundleID: bundleID) + } + + let arguments = try adapter.arguments( + romPath: request.romURL.path(percentEncoded: false), + options: profile.options, + extraArguments: profile.extraArguments + ) + let plan = LaunchPlan( + appURL: app.bundleURL, + executableURL: app.executableURL, + bundleIdentifier: app.bundleIdentifier, + arguments: arguments, + strategy: profile.strategy ?? adapter.defaultStrategy, + activates: true, + createsNewInstance: createsNewInstance + ) + return PreparedLaunch(plan: plan, app: app, warnings: warnings) + } + + public func launch(_ prepared: PreparedLaunch) async throws(LaunchError) -> LaunchedProcess { + let plan = prepared.plan + guard let launcher = launchers[plan.strategy] else { + throw .launchFailed(String(localized: "estratégia “\(plan.strategy.rawValue)” indisponível")) + } + let interval = Log.launchSignposter.beginInterval("launch") + defer { Log.launchSignposter.endInterval("launch", interval) } + + Log.launch.info(""" + Abrindo \(plan.bundleIdentifier ?? "?", privacy: .public) via \(plan.strategy.rawValue, privacy: .public) \ + com \(plan.arguments, privacy: .private) + """) + do { + let launched = try await launcher.launch(plan) + Log.launch.info("Emulador iniciado, pid \(launched.pid, privacy: .public)") + return launched + } catch { + Log.launch.error("Falha ao abrir o emulador: \(error.message, privacy: .private)") + throw error + } + } + + private func validateROM(_ url: URL, supportedExtensions: Set) throws(LaunchError) { + switch fileSystem.kind(of: url) { + case .missing: throw .romNotFound + case .directory: throw .romIsDirectory + case .regularFile: break + } + guard fileSystem.isReadable(url) else { throw .romUnreadable } + let fileExtension = url.pathExtension.lowercased() + guard supportedExtensions.contains(fileExtension) else { throw .unsupportedFormat(fileExtension) } + } +} diff --git a/Sources/RetroXCore/Launching/LaunchError.swift b/Sources/RetroXCore/Launching/LaunchError.swift new file mode 100644 index 0000000..acd3259 --- /dev/null +++ b/Sources/RetroXCore/Launching/LaunchError.swift @@ -0,0 +1,102 @@ +import Foundation + +/// Ações de correção oferecidas junto com cada erro (RNF-06). +public enum RecoveryAction: Sendable, Hashable { + case chooseEmulator + case locateROM + case retry + case openSettings + case bringEmulatorToFront + case showRosettaHelp + + public var title: String { + switch self { + case .chooseEmulator: String(localized: "Escolher emulador") + case .locateROM: String(localized: "Localizar arquivo") + case .retry: String(localized: "Tentar novamente") + case .openSettings: String(localized: "Abrir ajustes") + case .bringEmulatorToFront: String(localized: "Trazer emulador para frente") + case .showRosettaHelp: String(localized: "Como instalar o Rosetta") + } + } +} + +public enum LaunchError: Error, Sendable, Equatable { + case emulatorNotConfigured(GameSystemID) + case emulatorDoesNotSupportSystem(GameSystemID) + case emulatorNotFound + case emulatorBundleInvalid + case emulatorNotExecutable + case architectureUnsupported(rosettaMissing: Bool) + case romNotFound + case romIsDirectory + case romUnreadable + case unsupportedFormat(String) + case emulatorAlreadyRunning(bundleID: String) + case invalidExtraArgument(String) + case launchFailed(String) + case exitedEarly(status: Int32?, after: Duration) + + public var message: String { + switch self { + case .emulatorNotConfigured(let system): + String(localized: "Nenhum emulador configurado para \(Self.name(of: system)).") + case .emulatorDoesNotSupportSystem(let system): + String(localized: "O emulador escolhido não abre jogos de \(Self.name(of: system)).") + case .emulatorNotFound: + String(localized: "O emulador não foi encontrado no local configurado.") + case .emulatorBundleInvalid: + String(localized: "O item escolhido não parece ser um app de emulador válido.") + case .emulatorNotExecutable: + String(localized: "O macOS não permite executar o emulador escolhido.") + case .architectureUnsupported(rosettaMissing: true): + String(localized: "Este emulador foi feito para Mac com Intel e precisa do Rosetta, que não está instalado.") + case .architectureUnsupported(rosettaMissing: false): + String(localized: "Este emulador não roda na arquitetura deste Mac.") + case .romNotFound: + String(localized: "O arquivo do jogo não foi encontrado.") + case .romIsDirectory: + String(localized: "O item escolhido é uma pasta, não um arquivo de jogo.") + case .romUnreadable: + String(localized: "Sem permissão para ler o arquivo do jogo.") + case .unsupportedFormat(let fileExtension): + String(localized: "O formato “.\(fileExtension)” não é aberto por este emulador.") + case .emulatorAlreadyRunning: + String(localized: "O emulador já está aberto. Feche o jogo atual antes de abrir outro.") + case .invalidExtraArgument(let argument): + String(localized: "O argumento extra “\(argument)” não é permitido.") + case .launchFailed(let reason): + String(localized: "Não foi possível abrir o emulador: \(reason)") + case .exitedEarly: + String(localized: "O emulador fechou logo depois de abrir. Confira a BIOS e as configurações dele.") + } + } + + public var recoveryActions: [RecoveryAction] { + switch self { + case .emulatorNotConfigured, .emulatorDoesNotSupportSystem, .emulatorNotFound, + .emulatorBundleInvalid, .emulatorNotExecutable, .architectureUnsupported(rosettaMissing: false): + [.chooseEmulator] + case .architectureUnsupported(rosettaMissing: true): + [.showRosettaHelp, .chooseEmulator] + case .romNotFound, .romIsDirectory, .romUnreadable: + [.locateROM] + case .unsupportedFormat: + [.chooseEmulator, .locateROM] + case .emulatorAlreadyRunning: + [.bringEmulatorToFront] + case .invalidExtraArgument: + [.openSettings] + case .launchFailed, .exitedEarly: + [.retry, .openSettings] + } + } + + private static func name(of system: GameSystemID) -> String { + SystemCatalog.system(system)?.shortName ?? system.rawValue + } +} + +extension LaunchError: LocalizedError { + public var errorDescription: String? { message } +} diff --git a/Sources/RetroXCore/Launching/LaunchModels.swift b/Sources/RetroXCore/Launching/LaunchModels.swift new file mode 100644 index 0000000..13aa3d1 --- /dev/null +++ b/Sources/RetroXCore/Launching/LaunchModels.swift @@ -0,0 +1,143 @@ +import Foundation + +/// Como o processo do emulador é criado. A escolha por adapter é registrada no ADR-0011. +public enum LaunchStrategy: String, Codable, Sendable, CaseIterable { + /// `NSWorkspace.openApplication`: LaunchServices, ativação e TCC por conta do emulador. + case workspace + /// `Process`: exec direto do binário; dá exit status, mas o emulador vira filho do RetroX. + case process +} + +/// Configuração de um emulador escolhida pelo usuário. +public struct EmulatorProfile: Sendable, Equatable, Identifiable { + public var id: UUID + public var name: String + public var adapterID: EmulatorAdapterID + public var appURL: URL + public var options: EmulatorOptions + public var extraArguments: [String] + /// `nil` usa a estratégia padrão do adapter. + public var strategy: LaunchStrategy? + + public init( + id: UUID = UUID(), + name: String, + adapterID: EmulatorAdapterID, + appURL: URL, + options: EmulatorOptions, + extraArguments: [String] = [], + strategy: LaunchStrategy? = nil + ) { + self.id = id + self.name = name + self.adapterID = adapterID + self.appURL = appURL + self.options = options + self.extraArguments = extraArguments + self.strategy = strategy + } +} + +public enum AlreadyRunningPolicy: Sendable, Equatable { + /// Padrão: não abre outra instância; o usuário decide (ADR-0003, emenda). + case refuse + case launchNewInstance +} + +public struct LaunchRequest: Sendable, Equatable { + public var romURL: URL + public var systemID: GameSystemID + public var profile: EmulatorProfile + public var alreadyRunningPolicy: AlreadyRunningPolicy + + public init(romURL: URL, systemID: GameSystemID, profile: EmulatorProfile, alreadyRunningPolicy: AlreadyRunningPolicy = .refuse) { + self.romURL = romURL + self.systemID = systemID + self.profile = profile + self.alreadyRunningPolicy = alreadyRunningPolicy + } +} + +/// Tudo o que é preciso para abrir o emulador, já validado. Puro: é o que o modo simulado mostra. +public struct LaunchPlan: Sendable, Equatable { + public let appURL: URL + public let executableURL: URL + public let bundleIdentifier: String? + /// argv sem o executável. + public let arguments: [String] + public let strategy: LaunchStrategy + public let activates: Bool + public let createsNewInstance: Bool +} + +public enum LaunchWarning: Sendable, Equatable { + case unexpectedBundleIdentifier(String?) + case runsUnderRosetta + case unknownArchitecture + + public var message: String { + switch self { + case .unexpectedBundleIdentifier(let found): + String(localized: "O app escolhido não se identifica como o emulador esperado (\(found ?? "sem identificador")).") + case .runsUnderRosetta: + String(localized: "Este emulador roda via Rosetta neste Mac.") + case .unknownArchitecture: + String(localized: "Não foi possível conferir a arquitetura do emulador.") + } + } +} + +public struct PreparedLaunch: Sendable, Equatable { + public let plan: LaunchPlan + public let app: AppBundleInfo + public let warnings: [LaunchWarning] +} + +public struct ProcessExit: Sendable, Equatable { + /// Só disponível quando o emulador é filho do RetroX (estratégia `process`). + public let status: Int32? + public let endedAt: Date + + public init(status: Int32?, endedAt: Date) { + self.status = status + self.endedAt = endedAt + } +} + +public struct LaunchedProcess: Sendable { + public let pid: pid_t + public let launchedAt: Date + public let plan: LaunchPlan + /// Entrega um único `ProcessExit` quando o processo termina. Tem um só consumidor. + public let exits: AsyncStream + + public init(pid: pid_t, launchedAt: Date, plan: LaunchPlan, exits: AsyncStream) { + self.pid = pid + self.launchedAt = launchedAt + self.plan = plan + self.exits = exits + } + + public func waitForExit() async -> ProcessExit? { + for await exit in exits { return exit } + return nil + } +} + +/// Como terminou uma sessão de jogo, do ponto de vista do RetroX. +public enum SessionOutcome: Sendable, Equatable { + case ended(after: Duration) + /// Fechou cedo demais para ter sido uma partida: provável falha (BIOS, formato, configuração). + case exitedEarly(status: Int32?, after: Duration) + + public static let earlyExitWindow: Duration = .seconds(5) + + public static func classify(launchedAt: Date, exit: ProcessExit, earlyExitWindow: Duration = earlyExitWindow) -> SessionOutcome { + let elapsed = Duration.milliseconds(Int64((exit.endedAt.timeIntervalSince(launchedAt) * 1000).rounded())) + return elapsed < earlyExitWindow ? .exitedEarly(status: exit.status, after: elapsed) : .ended(after: elapsed) + } +} + +public protocol ProcessLaunching: Sendable { + func launch(_ plan: LaunchPlan) async throws(LaunchError) -> LaunchedProcess +} diff --git a/Sources/RetroXCore/Launching/ProcessExitMonitor.swift b/Sources/RetroXCore/Launching/ProcessExitMonitor.swift new file mode 100644 index 0000000..66b6fad --- /dev/null +++ b/Sources/RetroXCore/Launching/ProcessExitMonitor.swift @@ -0,0 +1,22 @@ +import Foundation + +enum ProcessExitMonitor { + /// Avisa quando um processo qualquer termina, mesmo que não seja filho do RetroX (kqueue via Dispatch). + /// Não depende de run loop, então funciona também na CLI de diagnóstico. + static func exits(of pid: pid_t) -> AsyncStream { + AsyncStream { continuation in + let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: .global(qos: .utility)) + source.setEventHandler { + continuation.yield(ProcessExit(status: nil, endedAt: Date())) + continuation.finish() + } + continuation.onTermination = { _ in source.cancel() } + source.resume() + // Se o processo terminou antes de o monitor existir, o evento nunca chega. + if kill(pid, 0) == -1, errno == ESRCH { + continuation.yield(ProcessExit(status: nil, endedAt: Date())) + continuation.finish() + } + } + } +} diff --git a/Sources/RetroXCore/Launching/WorkspaceLauncher.swift b/Sources/RetroXCore/Launching/WorkspaceLauncher.swift new file mode 100644 index 0000000..6a9ed36 --- /dev/null +++ b/Sources/RetroXCore/Launching/WorkspaceLauncher.swift @@ -0,0 +1,23 @@ +import AppKit + +/// Abre o emulador pelo LaunchServices. Os argumentos só chegam se o app ainda não estiver aberto +/// e se o RetroX não estiver em sandbox (doc de `NSWorkspace.OpenConfiguration.arguments`). +public struct WorkspaceLauncher: ProcessLaunching { + public init() {} + + public func launch(_ plan: LaunchPlan) async throws(LaunchError) -> LaunchedProcess { + let configuration = NSWorkspace.OpenConfiguration() + configuration.arguments = plan.arguments + configuration.activates = plan.activates + configuration.createsNewApplicationInstance = plan.createsNewInstance + configuration.addsToRecentItems = false + + let pid: pid_t + do { + pid = try await NSWorkspace.shared.openApplication(at: plan.appURL, configuration: configuration).processIdentifier + } catch { + throw .launchFailed(error.localizedDescription) + } + return LaunchedProcess(pid: pid, launchedAt: Date(), plan: plan, exits: ProcessExitMonitor.exits(of: pid)) + } +} diff --git a/Sources/RetroXCore/Library/CoverMatcher.swift b/Sources/RetroXCore/Library/CoverMatcher.swift new file mode 100644 index 0000000..a7a504b --- /dev/null +++ b/Sources/RetroXCore/Library/CoverMatcher.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Associa capas locais (fornecidas pelo usuário) a ROMs pelo nome do arquivo. Sem rede (ADR-0006). +public enum CoverMatcher { + public static let imageExtensions: Set = ["png", "jpg", "jpeg", "webp"] + + /// Palavras que costumam enfeitar o nome de uma capa e não identificam o jogo. + static let noiseWords: Set = ["cover", "covers", "front", "box", "boxart", "capa", "art"] + + /// Chave curta demais casaria com jogos demais ("gt" é prefixo de muita coisa). + static let minimumKeyLength = 3 + + /// "GT 4 Spec II v1.08.iso" → "gt4speciiv108"; "gt4-cover.png" → "gt4". + static func key(_ filename: String) -> String { + let base = (filename as NSString).deletingPathExtension + let folded = base.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: nil) + return folded + .split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + .map(String.init) + .filter { !noiseWords.contains($0) } + .joined() + } + + /// Prefere o serial do disco (como o PCSX2 nomeia as capas que baixa); depois o nome idêntico; senão, + /// a capa cujo nome é o prefixo mais longo do nome da ROM (ou vice-versa). + public static func match(romFilename: String, serial: String? = nil, covers: [URL]) -> URL? { + PreparedCovers(covers).match(romFilename: romFilename, serial: serial) + } + + struct PreparedCovers { + private struct Cover { + let url: URL + let basename: String + let key: String + } + + private let covers: [Cover] + + init(_ urls: [URL]) { + covers = urls.map { url in + Cover(url: url, basename: (url.lastPathComponent as NSString).deletingPathExtension, key: CoverMatcher.key(url.lastPathComponent)) + } + } + + func match(romFilename: String, serial: String? = nil) -> URL? { + if let serial, let cover = covers.first(where: { + $0.basename.caseInsensitiveCompare(serial) == .orderedSame + }) { + return cover.url + } + let romKey = key(romFilename) + guard romKey.count >= minimumKeyLength else { return nil } + var best: (url: URL, length: Int)? + for cover in covers { + let coverKey = cover.key + guard coverKey.count >= minimumKeyLength else { continue } + if coverKey == romKey { return cover.url } + if romKey.hasPrefix(coverKey) || coverKey.hasPrefix(romKey) { + let length = min(coverKey.count, romKey.count) + if length > (best?.length ?? 0) { best = (cover.url, length) } + } + } + return best?.url + } + } +} diff --git a/Sources/RetroXCore/Library/CoverThumbnailer.swift b/Sources/RetroXCore/Library/CoverThumbnailer.swift new file mode 100644 index 0000000..ea1f194 --- /dev/null +++ b/Sources/RetroXCore/Library/CoverThumbnailer.swift @@ -0,0 +1,19 @@ +import Foundation +import ImageIO + +public enum CoverThumbnailer { + /// Decodifica a capa já reduzida (ImageIO), sem carregar a imagem inteira em memória (ADR-0008). + /// Síncrono: chame fora da main thread. + public static func thumbnail(of url: URL, maxPixelSize: Int) -> CGImage? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, [kCGImageSourceShouldCache: false] as CFDictionary) else { + return nil + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + ] + return CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) + } +} diff --git a/Sources/RetroXCore/Library/Game.swift b/Sources/RetroXCore/Library/Game.swift new file mode 100644 index 0000000..3cc9bb2 --- /dev/null +++ b/Sources/RetroXCore/Library/Game.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct Game: Identifiable, Hashable, Sendable { + /// Caminho padronizado da ROM. Estável enquanto o arquivo não muda de lugar; a Fase 2 troca por UUID + /// persistido com detecção de arquivo movido (ADR-0004, ADR-0013). + public let id: String + public let title: String + public let systemID: GameSystemID + public let pluginID: EmulatorPluginID + public let romURL: URL + public let fileSize: Int64 + /// Serial do disco ("SLUS-21490"), quando o formato permite ler. + public let serial: String? + /// O que o banco de dados do emulador sabe pelo serial (nome oficial, região, compatibilidade). + public let info: GameIndexEntry? + public let coverURL: URL? + public let searchKey: String + + public init( + romURL: URL, systemID: GameSystemID, pluginID: EmulatorPluginID, fileSize: Int64, + serial: String? = nil, info: GameIndexEntry? = nil, coverURL: URL? + ) { + let title = TitleParser.displayTitle(fromFilename: romURL.lastPathComponent) + self.id = romURL.standardizedFileURL.path(percentEncoded: false) + self.title = title + self.systemID = systemID + self.pluginID = pluginID + self.romURL = romURL + self.fileSize = fileSize + self.serial = serial + self.info = info + self.coverURL = coverURL + self.searchKey = TitleParser.searchKey(title) + } + + public var fileExtension: String { romURL.pathExtension.uppercased() } + + /// O mesmo jogo com outra capa (por exemplo, a escolhida pelo usuário). + public func withCover(_ url: URL?) -> Game { + Game(romURL: romURL, systemID: systemID, pluginID: pluginID, fileSize: fileSize, serial: serial, info: info, coverURL: url) + } + + /// Título para buscar informações: o nome oficial do emulador, se houver; senão, o do arquivo sem marcas. + public var searchTitle: String { + info?.name ?? TitleParser.searchTitle(title) + } +} + +/// O que o usuário fez com um jogo: favorito e histórico de partidas. +public struct GameHistory: Codable, Sendable, Equatable { + public var isFavorite: Bool + public var lastPlayedAt: Date? + public var playCount: Int + + public init(isFavorite: Bool = false, lastPlayedAt: Date? = nil, playCount: Int = 0) { + self.isFavorite = isFavorite + self.lastPlayedAt = lastPlayedAt + self.playCount = playCount + } +} diff --git a/Sources/RetroXCore/Library/GameCustomization.swift b/Sources/RetroXCore/Library/GameCustomization.swift new file mode 100644 index 0000000..b3545f9 --- /dev/null +++ b/Sources/RetroXCore/Library/GameCustomization.swift @@ -0,0 +1,47 @@ +import CryptoKit +import Foundation + +/// O que o usuário mudou num jogo, compartilhado entre perfis: capa própria e informações buscadas. +public struct GameCustomization: Codable, Sendable, Equatable { + /// Nome do arquivo da capa dentro de `Artwork/`. + public var coverFile: String? + public var metadata: GameMetadata? + + public init(coverFile: String? = nil, metadata: GameMetadata? = nil) { + self.coverFile = coverFile + self.metadata = metadata + } +} + +public struct ArtworkStorage: Sendable { + public let root: URL + + public init(root: URL = RetroXDirectories.applicationSupport) { + self.root = root + } + + public var customizations: JSONFileStore<[String: GameCustomization]> { + JSONFileStore(url: root.appending(path: "games.json")) + } + + public var artworkDirectory: URL { + root.appending(path: "Artwork", directoryHint: .isDirectory) + } + + public func coverURL(for customization: GameCustomization?) -> URL? { + customization?.coverFile.map { artworkDirectory.appending(path: $0) } + } + + /// Nome estável e sem caracteres problemáticos para a capa de um jogo. + public static func coverFileName(for gameID: String) -> String { + let digest = SHA256.hash(data: Data(gameID.utf8)).prefix(12).map { String(format: "%02x", $0) }.joined() + return "\(digest).png" + } + + /// Copia a imagem escolhida como capa do jogo e devolve o nome do arquivo. + public func importCover(from source: URL, for gameID: String) throws -> String { + let name = Self.coverFileName(for: gameID) + try ImageImporter.importPNG(from: source, to: artworkDirectory.appending(path: name), maxPixelSize: 1200) + return name + } +} diff --git a/Sources/RetroXCore/Library/GameScanner.swift b/Sources/RetroXCore/Library/GameScanner.swift new file mode 100644 index 0000000..6d2e7e7 --- /dev/null +++ b/Sources/RetroXCore/Library/GameScanner.swift @@ -0,0 +1,65 @@ +import Foundation + +public struct ScannedROM: Sendable, Equatable { + public let url: URL + public let fileSize: Int64 +} + +public enum GameScanner { + /// Lista as ROMs das pastas, sem abrir nem alterar os arquivos (ADR-0004). Pula arquivos ocultos e + /// o conteúdo de packages, e conta só uma vez o mesmo arquivo alcançado por caminhos diferentes. + /// Síncrono e possivelmente lento: chame fora da main thread. + public static func scan(_ directories: [ROMDirectory], extensions: Set) -> [ScannedROM] { + var seen: Set = [] + var result: [ScannedROM] = [] + for directory in directories { + var options: FileManager.DirectoryEnumerationOptions = [.skipsHiddenFiles, .skipsPackageDescendants] + if !directory.isRecursive { options.insert(.skipsSubdirectoryDescendants) } + guard let enumerator = FileManager.default.enumerator( + at: directory.url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: options + ) else { continue } + + for case let url as URL in enumerator { + if Task.isCancelled { return result } + guard extensions.contains(url.pathExtension.lowercased()), + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true + else { continue } + let identity = url.resolvingSymlinksInPath().standardizedFileURL.path(percentEncoded: false) + guard seen.insert(identity).inserted else { continue } + result.append(ScannedROM(url: url, fileSize: Int64(values.fileSize ?? 0))) + } + } + return result + } +} + +public enum LibraryBuilder { + /// Monta os jogos de um plugin pronto: varre as pastas de ROM e associa capas pelo nome. + public static func games(for plugin: any EmulatorPlugin, discovery: PluginDiscovery, fileSystem: any FileSystemProbing = LocalFileSystem()) -> [Game] { + // Plugins de um sistema só (o PCSX2) não precisam de detecção; os de vários sistemas usarão o ADR-0012. + guard let system = plugin.systems.first, plugin.systems.count == 1 else { return [] } + let extensions = plugin.adapter.supportedExtensions(for: system) + let covers = discovery.coverDirectories + .flatMap { fileSystem.contentsOfDirectory(at: $0) } + .filter { CoverMatcher.imageExtensions.contains($0.pathExtension.lowercased()) } + let preparedCovers = CoverMatcher.PreparedCovers(covers) + // O PCSX2 nomeia as capas que baixa pelo serial do disco, e o banco de dados dele também usa o serial. + let roms = GameScanner.scan(discovery.romDirectories, extensions: extensions) + .map { rom in (rom, system == .ps2 ? PS2DiscReader.serial(of: rom.url) : nil) } + let index = discovery.gameIndexFile.map { PCSX2GameIndex.entries(for: Set(roms.compactMap(\.1)), in: $0) } ?? [:] + return roms + .map { rom, serial in + Game( + romURL: rom.url, + systemID: system, + pluginID: plugin.id, + fileSize: rom.fileSize, + serial: serial, + info: serial.flatMap { index[$0] }, + coverURL: preparedCovers.match(romFilename: rom.url.lastPathComponent, serial: serial) + ) + } + .sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } +} diff --git a/Sources/RetroXCore/Library/PS2DiscReader.swift b/Sources/RetroXCore/Library/PS2DiscReader.swift new file mode 100644 index 0000000..b1c55bc --- /dev/null +++ b/Sources/RetroXCore/Library/PS2DiscReader.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Lê o serial de um disco de PS2 em ISO (por exemplo, "SLUS-21490"), do mesmo jeito que o PCSX2 nomeia +/// as capas que baixa. Lê só o descritor de volume, a pasta raiz e o `SYSTEM.CNF`: poucos KB, sem hash +/// e sem alterar o arquivo. Formatos comprimidos (CHD, CSO, ZSO) não são lidos. +public enum PS2DiscReader { + static let sectorSize = 2048 + static let maxDirectorySize = 64 * 1024 + static let maxConfigSize = 4 * 1024 + + public static func serial(of url: URL) -> String? { + guard url.pathExtension.lowercased() == "iso", let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + + // Descritor de volume primário no setor 16: tipo 1 + "CD001". + guard let descriptor = read(handle, offset: 16 * sectorSize, length: sectorSize), + descriptor.count == sectorSize, descriptor[0] == 1, + String(decoding: descriptor[1...5], as: UTF8.self) == "CD001" + else { return nil } + let blockSize = Int(descriptor.uint16LE(at: 128) ?? 0) + guard blockSize > 0, + let rootSector = descriptor.uint32LE(at: 156 + 2), + let rootSize = descriptor.uint32LE(at: 156 + 10), + let root = read(handle, offset: Int(rootSector) * blockSize, length: min(Int(rootSize), maxDirectorySize)), + let entry = findFile(named: "SYSTEM.CNF", in: root, blockSize: blockSize), + let config = read(handle, offset: entry.sector * blockSize, length: min(entry.size, maxConfigSize)) + else { return nil } + return serial(fromSystemConfig: String(decoding: config, as: UTF8.self)) + } + + /// `BOOT2 = cdrom0:\SLUS_214.90;1` → "SLUS-21490". Só PS2 (`BOOT2`); discos de PS1 usam `BOOT`. + static func serial(fromSystemConfig text: String) -> String? { + let ini = INIFile(text) + guard let boot = ini.value("BOOT2", in: "") else { return nil } + let file = boot.split(whereSeparator: { $0 == "\\" || $0 == ":" || $0 == "/" }).last.map(String.init) ?? boot + let name = file.split(separator: ";").first.map(String.init) ?? file + let parts = name.split(separator: "_", maxSplits: 1) + guard parts.count == 2, parts[0].count == 4, parts[0].allSatisfy(\.isLetter) else { return nil } + let digits = parts[1].filter(\.isNumber) + guard digits.count == 5 else { return nil } + return "\(parts[0].uppercased())-\(digits)" + } + + private static func findFile(named name: String, in directory: Data, blockSize: Int) -> (sector: Int, size: Int)? { + var offset = 0 + while offset < directory.count { + let length = Int(directory[directory.startIndex + offset]) + if length == 0 { + // Registros não atravessam o fim do bloco: o resto do bloco é preenchimento. + offset = (offset / blockSize + 1) * blockSize + continue + } + guard offset + 33 <= directory.count, length >= 34, offset + length <= directory.count else { return nil } + let nameLength = Int(directory[directory.startIndex + offset + 32]) + guard offset + 33 + nameLength <= offset + length else { return nil } + let start = directory.startIndex + offset + 33 + let entryName = String(decoding: directory[start..<(start + nameLength)], as: UTF8.self) + if entryName.split(separator: ";").first.map({ $0.uppercased() == name }) == true, + let sector = directory.uint32LE(at: offset + 2), let size = directory.uint32LE(at: offset + 10) { + return (Int(sector), Int(size)) + } + offset += length + } + return nil + } + + private static func read(_ handle: FileHandle, offset: Int, length: Int) -> Data? { + guard length > 0, (try? handle.seek(toOffset: UInt64(offset))) != nil else { return nil } + return try? handle.read(upToCount: length) + } +} + +extension Data { + func uint16LE(at offset: Int) -> UInt16? { + guard offset >= 0, offset + 2 <= count else { return nil } + return UInt16(self[startIndex + offset]) | UInt16(self[startIndex + offset + 1]) << 8 + } + + func uint32LE(at offset: Int) -> UInt32? { + guard offset >= 0, offset + 4 <= count else { return nil } + return (0..<4).reduce(UInt32(0)) { value, byte in value | UInt32(self[startIndex + offset + byte]) << (8 * byte) } + } +} diff --git a/Sources/RetroXCore/Metadata/WikipediaMetadataProvider.swift b/Sources/RetroXCore/Metadata/WikipediaMetadataProvider.swift new file mode 100644 index 0000000..6184019 --- /dev/null +++ b/Sources/RetroXCore/Metadata/WikipediaMetadataProvider.swift @@ -0,0 +1,247 @@ +import Foundation + +/// Informações de um jogo vindas de uma fonte pública, guardadas com a origem (ADR-0014). +public struct GameMetadata: Codable, Sendable, Equatable { + public var title: String + public var summary: String + public var shortDescription: String? + public var releaseYear: Int? + public var developers: [String] + public var publishers: [String] + public var genres: [String] + public var sourceName: String + public var sourceURL: URL? + public var fetchedAt: Date + + public init( + title: String, summary: String, shortDescription: String? = nil, releaseYear: Int? = nil, + developers: [String] = [], publishers: [String] = [], genres: [String] = [], + sourceName: String, sourceURL: URL? = nil, fetchedAt: Date = .now + ) { + self.title = title + self.summary = summary + self.shortDescription = shortDescription + self.releaseYear = releaseYear + self.developers = developers + self.publishers = publishers + self.genres = genres + self.sourceName = sourceName + self.sourceURL = sourceURL + self.fetchedAt = fetchedAt + } +} + +/// Um resultado de busca para o usuário escolher. Nada é salvo sem a escolha dele. +public struct MetadataCandidate: Identifiable, Sendable, Hashable { + public let title: String + public let snippet: String + public let language: String + + public var id: String { "\(language):\(title)" } +} + +public enum MetadataError: Error, Equatable { + case http(Int) + case network(String) + case invalidResponse +} + +public protocol HTTPFetching: Sendable { + func data(from url: URL) async throws -> Data +} + +/// URLSession com identificação, como pede a política da Wikimedia. +public struct URLSessionHTTP: HTTPFetching { + public static let userAgent = "RetroX/0.1 (launcher de emuladores para macOS)" + + public init() {} + + public func data(from url: URL) async throws -> Data { + var request = URLRequest(url: url, timeoutInterval: 15) + request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw MetadataError.network(error.localizedDescription) + } + guard let http = response as? HTTPURLResponse else { throw MetadataError.invalidResponse } + guard (200..<300).contains(http.statusCode) else { throw MetadataError.http(http.statusCode) } + return data + } +} + +/// Busca na Wikipedia (português, depois inglês) e completa com dados estruturados do Wikidata quando +/// disponíveis. Não precisa de conta nem de chave. Texto da Wikipedia: CC BY-SA, com link para a origem. +public struct WikipediaMetadataProvider: Sendable { + private let http: any HTTPFetching + private let languages: [String] + + public init(http: any HTTPFetching = URLSessionHTTP(), languages: [String] = ["pt", "en"]) { + self.http = http + self.languages = languages + } + + public func search(_ query: String) async throws -> [MetadataCandidate] { + var lastError: Error? + for language in languages { + do { + let results = try await search(query, language: language) + if !results.isEmpty { return results } + } catch { + lastError = error + } + } + if let lastError { throw lastError } + return [] + } + + public func metadata(for candidate: MetadataCandidate) async throws -> GameMetadata { + let encodedTitle = candidate.title.replacingOccurrences(of: " ", with: "_") + .addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(CharacterSet(charactersIn: "/?#"))) + ?? candidate.title + let url = URL(string: "https://\(candidate.language).wikipedia.org/api/rest_v1/page/summary/\(encodedTitle)")! + let summary = try await JSONDecoder().decode(Summary.self, from: http.data(from: url)) + var metadata = GameMetadata( + title: summary.title, + summary: summary.extract ?? "", + shortDescription: summary.description, + sourceName: "Wikipedia (\(candidate.language))", + sourceURL: summary.contentURLs?.desktop?.page.flatMap(URL.init(string:)) + ) + // O Wikidata é um complemento: se estiver fora do ar, fica só o resumo. + if let item = summary.wikibaseItem { + try? await enrich(&metadata, item: item, language: candidate.language) + } + return metadata + } + + // MARK: Wikipedia + + private func search(_ query: String, language: String) async throws -> [MetadataCandidate] { + var components = URLComponents(string: "https://\(language).wikipedia.org/w/api.php")! + let hint = language == "pt" ? "jogo eletrônico" : "video game" + components.queryItems = [ + URLQueryItem(name: "action", value: "query"), + URLQueryItem(name: "list", value: "search"), + URLQueryItem(name: "srsearch", value: "\(query) \(hint)"), + URLQueryItem(name: "srlimit", value: "6"), + URLQueryItem(name: "srprop", value: "snippet"), + URLQueryItem(name: "format", value: "json"), + URLQueryItem(name: "formatversion", value: "2"), + ] + let response = try await JSONDecoder().decode(SearchResponse.self, from: http.data(from: components.url!)) + return response.query.search.map { + MetadataCandidate(title: $0.title, snippet: Self.plainText($0.snippet), language: language) + } + } + + /// "Test Drive" → "Test Drive". + static func plainText(_ html: String) -> String { + var text = html.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + for (entity, character) in [""": "\"", "&": "&", "'": "'", "<": "<", ">": ">", " ": " "] { + text = text.replacingOccurrences(of: entity, with: character) + } + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: Wikidata + + private func enrich(_ metadata: inout GameMetadata, item: String, language: String) async throws { + let claims = try await JSONDecoder().decode(EntitiesResponse.self, from: http.data(from: wikidataURL(ids: [item], props: "claims", language: language))) + .entities[item]?.claims ?? [:] + metadata.releaseYear = claims["P577"]?.compactMap(\.mainsnak.datavalue?.value.year).min() + let developers = claims["P178"]?.compactMap(\.mainsnak.datavalue?.value.id) ?? [] + let publishers = claims["P123"]?.compactMap(\.mainsnak.datavalue?.value.id) ?? [] + let genres = claims["P136"]?.compactMap(\.mainsnak.datavalue?.value.id) ?? [] + let ids = Array(Set(developers + publishers + genres)).sorted() + guard !ids.isEmpty else { return } + let labels = try await JSONDecoder().decode(EntitiesResponse.self, from: http.data(from: wikidataURL(ids: ids, props: "labels", language: language))) + .entities.compactMapValues { $0.labels?[language]?.value ?? $0.labels?["en"]?.value } + metadata.developers = developers.compactMap { labels[$0] } + metadata.publishers = publishers.compactMap { labels[$0] } + metadata.genres = genres.compactMap { labels[$0] } + } + + private func wikidataURL(ids: [String], props: String, language: String) -> URL { + var components = URLComponents(string: "https://www.wikidata.org/w/api.php")! + components.queryItems = [ + URLQueryItem(name: "action", value: "wbgetentities"), + URLQueryItem(name: "ids", value: ids.joined(separator: "|")), + URLQueryItem(name: "props", value: props), + URLQueryItem(name: "languages", value: Array(Set([language, "en"])).sorted().joined(separator: "|")), + URLQueryItem(name: "format", value: "json"), + ] + return components.url! + } +} + +// MARK: Formatos das respostas + +private struct SearchResponse: Decodable { + struct Query: Decodable { let search: [Hit] } + struct Hit: Decodable { + let title: String + let snippet: String + } + + let query: Query +} + +private struct Summary: Decodable { + struct ContentURLs: Decodable { + struct Desktop: Decodable { let page: String? } + let desktop: Desktop? + } + + let title: String + let extract: String? + let description: String? + let wikibaseItem: String? + let contentURLs: ContentURLs? + + enum CodingKeys: String, CodingKey { + case title, extract, description + case wikibaseItem = "wikibase_item" + case contentURLs = "content_urls" + } +} + +private struct EntitiesResponse: Decodable { + struct Entity: Decodable { + let claims: [String: [Claim]]? + let labels: [String: Label]? + } + + struct Label: Decodable { let value: String } + + struct Claim: Decodable { + struct Snak: Decodable { let datavalue: DataValue? } + let mainsnak: Snak + } + + /// `value` é um id de entidade ({"id": "Q123"}) ou uma data ({"time": "+2006-09-05T00:00:00Z"}). + struct DataValue: Decodable { + struct Value: Decodable { + let id: String? + let time: String? + + var year: Int? { + guard let time else { return nil } + return Int(time.drop(while: { $0 == "+" }).prefix(4)) + } + } + + let value: Value + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + value = (try? container.decode(Value.self, forKey: .value)) ?? Value(id: nil, time: nil) + } + + enum CodingKeys: String, CodingKey { case value } + } + + let entities: [String: Entity] +} diff --git a/Sources/RetroXCore/Plugins/EmulatorPlugin.swift b/Sources/RetroXCore/Plugins/EmulatorPlugin.swift new file mode 100644 index 0000000..f17ac06 --- /dev/null +++ b/Sources/RetroXCore/Plugins/EmulatorPlugin.swift @@ -0,0 +1,144 @@ +import Foundation + +/// Identificador estável de um plugin de emulador. O valor bruto é persistido na configuração. +public struct EmulatorPluginID: RawRepresentable, Hashable, Codable, Sendable, CustomStringConvertible { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public var description: String { rawValue } + + public static let pcsx2 = EmulatorPluginID(rawValue: "pcsx2") +} + +/// Um emulador que o usuário ativa no RetroX (ADR-0013). O plugin sabe encontrar o emulador e os jogos +/// a partir de uma pasta raiz, e entrega um `EmulatorAdapter` para abrir cada jogo. +public protocol EmulatorPlugin: Sendable { + var id: EmulatorPluginID { get } + var displayName: String { get } + var summary: String { get } + var systems: [GameSystemID] { get } + var adapter: any EmulatorAdapter { get } + + /// Inspeciona a pasta raiz escolhida pelo usuário. Só lê; pode demorar em discos lentos, então + /// deve ser chamado fora da main thread. + func discover(root: URL) -> PluginDiscovery +} + +public struct ROMDirectory: Sendable, Equatable, Hashable { + public enum Origin: Sendable, Equatable, Hashable { + /// Pasta encontrada dentro da pasta raiz do emulador. + case rootFolder + /// Pasta que o próprio emulador já conhece pela configuração dele. + case emulatorSettings + } + + public let url: URL + public let origin: Origin + public let isRecursive: Bool + + public init(url: URL, origin: Origin, isRecursive: Bool) { + self.url = url + self.origin = origin + self.isRecursive = isRecursive + } +} + +public enum PluginIssue: Sendable, Equatable { + case rootNotFound + case appNotFound + case noROMDirectories + case settingsFolderMissing(String) + + public var message: String { + switch self { + case .rootNotFound: + String(localized: "A pasta escolhida não existe mais ou não pode ser lida.") + case .appNotFound: + String(localized: "Nenhum app do emulador foi encontrado nesta pasta.") + case .noROMDirectories: + String(localized: "Nenhuma pasta de jogos encontrada (por exemplo, “roms”).") + case .settingsFolderMissing(let path): + String(localized: "A configuração do emulador aponta para uma pasta que não existe: \(path)") + } + } +} + +/// Como o controle está configurado no emulador (primeira porta). +public struct ControllerSummary: Sendable, Equatable { + public struct Binding: Sendable, Equatable, Hashable { + public let button: String + public let input: String + } + + public var type: String + /// Dispositivo das associações, por exemplo "SDL-0". + public var device: String? + public var bindings: [Binding] +} + +public struct PluginDiscovery: Sendable, Equatable { + public enum BIOSStatus: Sendable, Equatable { + case configured(String) + case fileMissing(String) + case notConfigured + /// O emulador ainda não tem configuração (nunca foi aberto ou é outra instalação). + case unknown + } + + public var root: URL + public var app: AppBundleInfo? + public var romDirectories: [ROMDirectory] = [] + public var coverDirectories: [URL] = [] + public var settingsFile: URL? + public var bios: BIOSStatus = .unknown + public var controller: ControllerSummary? + /// Banco de dados de jogos do emulador (no PCSX2, `GameIndex.yaml` dentro do app). + public var gameIndexFile: URL? + public var issues: [PluginIssue] = [] + + public init(root: URL) { + self.root = root + } + + public var isReady: Bool { app != nil && !romDirectories.isEmpty } +} + +/// Configuração de um plugin escolhida pelo usuário, persistida entre sessões. +public struct PluginConfig: Codable, Sendable, Equatable { + public var isEnabled: Bool + public var rootPath: String? + + public init(isEnabled: Bool = false, rootPath: String? = nil) { + self.isEnabled = isEnabled + self.rootPath = rootPath + } +} + +/// Plugins que o RetroX oferece, e outros emuladores que ele reconhece (ainda sem plugin). +public enum PluginCatalog { + /// Emulador conhecido sem plugin: aparece em Emuladores se estiver instalado (ou se o plugin estiver planejado). + public struct KnownEmulator: Sendable, Identifiable { + public let id: String + public let name: String + public let systems: String + /// Bundle IDs para procurar o app instalado pelo LaunchServices. + public let bundleIdentifiers: [String] + /// Plugin no plano (PRD); aparece mesmo sem estar instalado. + public let isPlanned: Bool + } + + public static let available: [any EmulatorPlugin] = [PCSX2Plugin()] + + public static let known: [KnownEmulator] = [ + KnownEmulator(id: "dolphin", name: "Dolphin", systems: "GameCube · Wii", bundleIdentifiers: ["org.dolphin-emu.dolphin"], isPlanned: true), + KnownEmulator(id: "ryujinx", name: "Ryujinx", systems: "Switch", bundleIdentifiers: ["org.ryujinx.Ryujinx"], isPlanned: false), + KnownEmulator(id: "rpcs3", name: "RPCS3", systems: "PS3", bundleIdentifiers: ["net.rpcs3.rpcs3"], isPlanned: false), + KnownEmulator(id: "ppsspp", name: "PPSSPP", systems: "PSP", bundleIdentifiers: ["org.ppsspp.ppsspp"], isPlanned: false), + KnownEmulator(id: "duckstation", name: "DuckStation", systems: "PS1", bundleIdentifiers: ["com.github.stenzek.duckstation"], isPlanned: false), + KnownEmulator(id: "melonds", name: "melonDS", systems: "NDS", bundleIdentifiers: ["net.kuribo64.melonDS"], isPlanned: false), + KnownEmulator(id: "cemu", name: "Cemu", systems: "Wii U", bundleIdentifiers: ["info.cemu.Cemu"], isPlanned: false), + ] +} diff --git a/Sources/RetroXCore/Plugins/PCSX2GameIndex.swift b/Sources/RetroXCore/Plugins/PCSX2GameIndex.swift new file mode 100644 index 0000000..fb6fd7a --- /dev/null +++ b/Sources/RetroXCore/Plugins/PCSX2GameIndex.swift @@ -0,0 +1,55 @@ +import Foundation + +/// O que o banco de dados de jogos do emulador sabe sobre um disco. +public struct GameIndexEntry: Codable, Sendable, Equatable, Hashable { + public let name: String + public let region: String? + /// Nota de compatibilidade do PCSX2, de 1 (não abre) a 5 (perfeito). + public let compatibility: Int? +} + +/// Lê o `GameIndex.yaml` que vem dentro do app do PCSX2 (serial → nome, região, compatibilidade). +/// Offline e sem alterar nada. Só os seriais pedidos são guardados; o resto do arquivo é ignorado. +public enum PCSX2GameIndex { + public static func entries(for serials: Set, in url: URL) -> [String: GameIndexEntry] { + guard !serials.isEmpty, let text = try? String(contentsOf: url, encoding: .utf8) else { return [:] } + return parse(text, serials: serials) + } + + static func parse(_ text: String, serials: Set) -> [String: GameIndexEntry] { + var result: [String: GameIndexEntry] = [:] + var current: String? + var fields: [String: String] = [:] + + func flush() { + if let current, let name = fields["name"] { + result[current] = GameIndexEntry(name: name, region: fields["region"], compatibility: fields["compat"].flatMap { Int($0) }) + } + fields = [:] + } + + for line in text.split(separator: "\n", omittingEmptySubsequences: true) { + if let first = line.first, !first.isWhitespace, first != "#" { + // Nova entrada de nível superior ("SLUS-21490:"). + flush() + let key = line.split(separator: ":", maxSplits: 1).first.map { String($0).trimmingCharacters(in: .whitespaces) } + current = key.flatMap { serials.contains($0) ? $0 : nil } + continue + } + guard current != nil else { continue } + let trimmed = line.trimmingCharacters(in: .whitespaces) + // Só os campos diretos da entrada (dois espaços de recuo), não os aninhados. + guard line.hasPrefix(" "), !line.hasPrefix(" "), + let colon = trimmed.firstIndex(of: ":") + else { continue } + let key = String(trimmed[..= 2 { + value = String(value.dropFirst().dropLast()).replacingOccurrences(of: "\\\"", with: "\"") + } + if ["name", "region", "compat"].contains(key) { fields[key] = value } + } + flush() + return result + } +} diff --git a/Sources/RetroXCore/Plugins/PCSX2Plugin.swift b/Sources/RetroXCore/Plugins/PCSX2Plugin.swift new file mode 100644 index 0000000..3dd6176 --- /dev/null +++ b/Sources/RetroXCore/Plugins/PCSX2Plugin.swift @@ -0,0 +1,156 @@ +import Foundation + +/// Plugin nativo do PCSX2. A partir da pasta raiz do emulador (a que guarda o `.app`, as ROMs, as capas e a BIOS), +/// encontra o app e as pastas de jogos, e lê, sem alterar, a configuração do próprio PCSX2 (`inis/PCSX2.ini`): +/// pastas da lista de jogos, pasta de capas e BIOS escolhida. +public struct PCSX2Plugin: EmulatorPlugin { + public let id = EmulatorPluginID.pcsx2 + public let displayName = "PCSX2" + public var summary: String { String(localized: "PlayStation 2 · plugin nativo") } + public let systems: [GameSystemID] = [.ps2] + public var adapter: any EmulatorAdapter { PCSX2Adapter() } + + /// Nomes de subpastas tratadas como pastas de jogos (sem diferença de caixa). + static let romFolderNames: Set = ["roms", "rom", "games", "jogos", "isos", "iso", "ps2"] + static let coverFolderNames: Set = ["covers", "capas", "boxart", "artwork"] + + private let fileSystem: any FileSystemProbing + private let defaultDataDirectory: URL + + /// `defaultDataDirectory` é onde o PCSX2 guarda a configuração fora do modo portátil. + public init( + fileSystem: any FileSystemProbing = LocalFileSystem(), + defaultDataDirectory: URL = URL.homeDirectory.appending(path: "Library/Application Support/PCSX2", directoryHint: .isDirectory) + ) { + self.fileSystem = fileSystem + self.defaultDataDirectory = defaultDataDirectory + } + + public func discover(root selected: URL) -> PluginDiscovery { + // Aceita também o próprio .app: a raiz passa a ser a pasta que o contém. + let root = selected.pathExtension.lowercased() == "app" ? selected.deletingLastPathComponent() : selected + var discovery = PluginDiscovery(root: root) + guard fileSystem.kind(of: root) == .directory else { + discovery.issues = [.rootNotFound] + return discovery + } + + let children = fileSystem.contentsOfDirectory(at: root) + discovery.app = findApp(in: children) + if discovery.app == nil { discovery.issues.append(.appNotFound) } + + let dataDirectory = discovery.app.flatMap(portableDataDirectory) ?? defaultDataDirectory + let settingsURL = dataDirectory.appending(path: "inis/PCSX2.ini", directoryHint: .notDirectory) + let settings = fileSystem.readText(of: settingsURL).map(INIFile.init) + if settings != nil { discovery.settingsFile = settingsURL } + + var romDirectories: [ROMDirectory] = [] + for child in children where fileSystem.kind(of: child) == .directory && child.pathExtension.lowercased() != "app" { + if Self.romFolderNames.contains(child.lastPathComponent.lowercased()) { + romDirectories.append(ROMDirectory(url: child, origin: .rootFolder, isRecursive: true)) + } else if Self.coverFolderNames.contains(child.lastPathComponent.lowercased()) { + discovery.coverDirectories.append(child) + } + } + let extensions = adapter.supportedExtensions(for: .ps2) + if children.contains(where: { extensions.contains($0.pathExtension.lowercased()) }) { + romDirectories.append(ROMDirectory(url: root, origin: .rootFolder, isRecursive: false)) + } + + if let settings { + let listed = settings.values("Paths", in: "GameList").map { ($0, false) } + + settings.values("RecursivePaths", in: "GameList").map { ($0, true) } + for (path, isRecursive) in listed where !path.isEmpty { + let url = resolve(path, relativeTo: dataDirectory) + if fileSystem.kind(of: url) == .directory { + romDirectories.append(ROMDirectory(url: url, origin: .emulatorSettings, isRecursive: isRecursive)) + } else { + discovery.issues.append(.settingsFolderMissing(path)) + } + } + let coversURL = resolve(settings.value("Covers", in: "Folders") ?? "covers", relativeTo: dataDirectory) + if fileSystem.kind(of: coversURL) == .directory { discovery.coverDirectories.append(coversURL) } + discovery.bios = biosStatus(settings: settings, dataDirectory: dataDirectory) + discovery.controller = Self.controller(settings) + } + if let app = discovery.app { + let index = app.bundleURL.appending(path: "Contents/Resources/GameIndex.yaml", directoryHint: .notDirectory) + if fileSystem.kind(of: index) == .regularFile { discovery.gameIndexFile = index } + } + + discovery.romDirectories = Self.merged(romDirectories) + if discovery.romDirectories.isEmpty { discovery.issues.append(.noROMDirectories) } + return discovery + } + + private func findApp(in children: [URL]) -> AppBundleInfo? { + let inspector = AppBundleInspector(fileSystem: fileSystem) + let expected = adapter.expectedBundleIdentifiers + // O app pode estar direto na raiz ou uma pasta abaixo (por exemplo, "PCSX2/PCSX2.app"). + let nested = children + .filter { fileSystem.kind(of: $0) == .directory && $0.pathExtension.lowercased() != "app" } + .flatMap { fileSystem.contentsOfDirectory(at: $0) } + let matches = (children + nested) + .filter { $0.pathExtension.lowercased() == "app" } + .compactMap { try? inspector.inspect($0) } + .filter { $0.bundleIdentifier.map(expected.contains) == true } + // Com versões lado a lado (ex.: depois de atualizar), usa a mais nova. + return matches.max { ($0.version ?? "").compare($1.version ?? "", options: .numeric) == .orderedAscending } + } + + /// No modo portátil, o PCSX2 guarda a configuração na pasta que contém o `.app`. + private func portableDataDirectory(for app: AppBundleInfo) -> URL? { + let folder = app.bundleURL.deletingLastPathComponent() + let markers = ["portable.ini", "portable.txt"].map { folder.appending(path: $0, directoryHint: .notDirectory) } + return markers.contains(where: { fileSystem.kind(of: $0) == .regularFile }) ? folder : nil + } + + static let padButtons = ["Cross", "Circle", "Square", "Triangle", "Up", "Down", "Left", "Right", + "L1", "R1", "L2", "R2", "L3", "R3", "Select", "Start"] + + /// `[Pad1]` do PCSX2.ini: tipo de controle e a que entrada cada botão do PS2 está ligado. + static func controller(_ settings: INIFile) -> ControllerSummary? { + guard let type = settings.value("Type", in: "Pad1"), type != "None" else { return nil } + let bindings = padButtons.compactMap { button in + settings.value(button, in: "Pad1").map { ControllerSummary.Binding(button: button, input: $0) } + } + let device = bindings.first?.input.split(separator: "/").first.map(String.init) + return ControllerSummary(type: type, device: device, bindings: bindings) + } + + private func biosStatus(settings: INIFile, dataDirectory: URL) -> PluginDiscovery.BIOSStatus { + guard let name = settings.value("BIOS", in: "Filenames") else { return .notConfigured } + let folder = resolve(settings.value("Bios", in: "Folders") ?? "bios", relativeTo: dataDirectory) + let file = folder.appending(path: name, directoryHint: .notDirectory) + return fileSystem.kind(of: file) == .regularFile ? .configured(name) : .fileMissing(name) + } + + private func resolve(_ path: String, relativeTo base: URL) -> URL { + let expanded = (path as NSString).expandingTildeInPath + let url = expanded.hasPrefix("/") + ? URL(filePath: expanded, directoryHint: .isDirectory) + : base.appending(path: expanded, directoryHint: .isDirectory) + return url.standardizedFileURL + } + + /// Junta pastas repetidas (raiz e configuração apontando para o mesmo lugar), mantendo a primeira origem. + static func merged(_ directories: [ROMDirectory]) -> [ROMDirectory] { + var result: [ROMDirectory] = [] + for directory in directories { + let key = directory.url.standardizedFileURL.path(percentEncoded: false).trimmingSuffix("/") + if let index = result.firstIndex(where: { $0.url.standardizedFileURL.path(percentEncoded: false).trimmingSuffix("/") == key }) { + let existing = result[index] + result[index] = ROMDirectory(url: existing.url, origin: existing.origin, isRecursive: existing.isRecursive || directory.isRecursive) + } else { + result.append(directory) + } + } + return result + } +} + +extension String { + func trimmingSuffix(_ suffix: String) -> String { + count > suffix.count && hasSuffix(suffix) ? String(dropLast(suffix.count)) : self + } +} diff --git a/Sources/RetroXCore/Profiles/Profile.swift b/Sources/RetroXCore/Profiles/Profile.swift new file mode 100644 index 0000000..30b4146 --- /dev/null +++ b/Sources/RetroXCore/Profiles/Profile.swift @@ -0,0 +1,101 @@ +import Foundation + +/// Um perfil de jogador: nome, foto, tema e o próprio histórico de favoritos e partidas. +public struct Profile: Codable, Identifiable, Sendable, Equatable, Hashable { + public var id: UUID + public var name: String + /// Nome do arquivo da foto dentro de `Avatars/`. + public var avatarFile: String? + public var theme: ThemeID + /// Acento do tema RetroX; os outros temas têm acento próprio. + public var accent: Palette.Accent + public var appearance: AppearancePreference + public var preferences: ProfilePreferences + + public init( + id: UUID = UUID(), + name: String, + avatarFile: String? = nil, + theme: ThemeID = .retrox, + accent: Palette.Accent = .coral, + appearance: AppearancePreference = .system, + preferences: ProfilePreferences = ProfilePreferences() + ) { + self.id = id + self.name = name + self.avatarFile = avatarFile + self.theme = theme + self.accent = accent + self.appearance = appearance + self.preferences = preferences + } + + /// Campos novos entram com valor padrão, para arquivos de versões anteriores continuarem abrindo. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + avatarFile = try container.decodeIfPresent(String.self, forKey: .avatarFile) + theme = (try? container.decodeIfPresent(ThemeID.self, forKey: .theme)) ?? .retrox + accent = (try? container.decodeIfPresent(Palette.Accent.self, forKey: .accent)) ?? .coral + appearance = (try? container.decodeIfPresent(AppearancePreference.self, forKey: .appearance)) ?? .system + preferences = (try? container.decodeIfPresent(ProfilePreferences.self, forKey: .preferences)) ?? ProfilePreferences() + } + + /// Iniciais para o avatar sem foto: "Ana Souza" → "AS", "Jogador 1" → "J1", "léo" → "L". + public var initials: String { + let words = name.split(whereSeparator: \.isWhitespace) + let letters = words.prefix(2).compactMap(\.first).map { String($0).uppercased() } + return letters.isEmpty ? "?" : letters.joined() + } +} + +public struct ProfilesState: Codable, Sendable, Equatable { + public var profiles: [Profile] + public var lastUsedID: UUID? + + public init(profiles: [Profile] = [], lastUsedID: UUID? = nil) { + self.profiles = profiles + self.lastUsedID = lastUsedID + } +} + +/// Arquivos dos perfis em `Application Support/RetroX`: `profiles.json`, `Avatars/` e `History/.json`. +public struct ProfileStorage: Sendable { + public let root: URL + + public init(root: URL = RetroXDirectories.applicationSupport) { + self.root = root + } + + public var state: JSONFileStore { + JSONFileStore(url: root.appending(path: "profiles.json")) + } + + public var avatarsDirectory: URL { + root.appending(path: "Avatars", directoryHint: .isDirectory) + } + + public func avatarURL(for profile: Profile) -> URL? { + profile.avatarFile.map { avatarsDirectory.appending(path: $0) } + } + + public func history(for profileID: UUID) -> JSONFileStore<[String: GameHistory]> { + JSONFileStore(url: root.appending(path: "History/\(profileID.uuidString).json")) + } + + /// Antes dos perfis, o histórico era um arquivo só (`history.json`). Na primeira vez, ele passa a + /// pertencer ao primeiro perfil, e o arquivo antigo fica guardado como `history.legacy.json`. + public func migrateLegacyHistory(into profileID: UUID) throws { + let legacy = root.appending(path: "history.json") + let destination = history(for: profileID) + guard FileManager.default.fileExists(atPath: legacy.path(percentEncoded: false)), + destination.load() == nil + else { return } + let entries = JSONFileStore<[String: GameHistory]>(url: legacy).load() ?? [:] + try destination.save(entries) + let backup = root.appending(path: "history.legacy.json") + try? FileManager.default.removeItem(at: backup) + try FileManager.default.moveItem(at: legacy, to: backup) + } +} diff --git a/Sources/RetroXCore/Profiles/ProfilePreferences.swift b/Sources/RetroXCore/Profiles/ProfilePreferences.swift new file mode 100644 index 0000000..b58911b --- /dev/null +++ b/Sources/RetroXCore/Profiles/ProfilePreferences.swift @@ -0,0 +1,177 @@ +import Foundation + +/// O que cada perfil personaliza além do tema: tela inicial, texto e movimento. +/// Campos novos entram com valor padrão, para arquivos antigos continuarem abrindo. +public struct ProfilePreferences: Codable, Sendable, Equatable, Hashable { + public enum HomeStyle: String, Codable, CaseIterable, Sendable, Identifiable { + /// Palco com o jogo em foco e prateleira embaixo. + case shelf + /// Capas grandes no centro, vizinhas inclinadas dos lados. + case carousel + /// Grade de capas com o jogo em foco no topo. + case grid + + public var id: Self { self } + + public var title: String { + switch self { + case .shelf: String(localized: "Prateleira") + case .carousel: String(localized: "Carrossel") + case .grid: String(localized: "Grade") + } + } + } + + public enum TileSize: String, Codable, CaseIterable, Sendable, Identifiable { + case small, medium, large + + public var id: Self { self } + + public var title: String { + switch self { + case .small: String(localized: "Pequenas") + case .medium: String(localized: "Médias") + case .large: String(localized: "Grandes") + } + } + + public var scale: Double { + switch self { + case .small: 0.8 + case .medium: 1 + case .large: 1.25 + } + } + } + + public enum StartShelf: String, Codable, CaseIterable, Sendable, Identifiable { + /// Recentes quando houver; senão, Todos. + case automatic, recent, all, favorites + + public var id: Self { self } + + public var title: String { + switch self { + case .automatic: String(localized: "Automática") + case .recent: String(localized: "Recentes") + case .all: String(localized: "Todos") + case .favorites: String(localized: "Favoritos") + } + } + } + + public enum FontStyle: String, Codable, CaseIterable, Sendable, Identifiable { + case rounded, standard, serif, mono + + public var id: Self { self } + + public var title: String { + switch self { + case .rounded: String(localized: "Arredondada") + case .standard: String(localized: "Padrão") + case .serif: String(localized: "Serifada") + case .mono: String(localized: "Monoespaçada") + } + } + } + + public enum TextSize: String, Codable, CaseIterable, Sendable, Identifiable { + case small, standard, large, extraLarge + + public var id: Self { self } + + public var title: String { + switch self { + case .small: String(localized: "Pequeno") + case .standard: String(localized: "Padrão") + case .large: String(localized: "Grande") + case .extraLarge: String(localized: "Extra grande") + } + } + + public var scale: Double { + switch self { + case .small: 0.9 + case .standard: 1 + case .large: 1.12 + case .extraLarge: 1.25 + } + } + } + + public enum AnimationLevel: String, Codable, CaseIterable, Sendable, Identifiable { + /// Tudo: elevação, deslizes, carrossel inclinado, fundo ambiente se movendo. + case full + /// Só esmaecimentos e trocas curtas (como o Reduzir Movimento do macOS). + case reduced + /// Sem animação. + case off + + public var id: Self { self } + + public var title: String { + switch self { + case .full: String(localized: "Completas") + case .reduced: String(localized: "Reduzidas") + case .off: String(localized: "Desligadas") + } + } + } + + public enum AnimationSpeed: String, Codable, CaseIterable, Sendable, Identifiable { + case calm, normal, fast + + public var id: Self { self } + + public var title: String { + switch self { + case .calm: String(localized: "Calma") + case .normal: String(localized: "Normal") + case .fast: String(localized: "Rápida") + } + } + + /// Multiplica as durações: maior é mais lento. + public var durationFactor: Double { + switch self { + case .calm: 1.35 + case .normal: 1 + case .fast: 0.7 + } + } + } + + public var homeStyle: HomeStyle = .shelf + public var tileSize: TileSize = .medium + public var startShelf: StartShelf = .automatic + public var fontStyle: FontStyle = .rounded + public var textSize: TextSize = .standard + /// Capa do jogo em foco, desfocada, atrás da tela. + public var ambientBackground = true + public var animationLevel: AnimationLevel = .full + public var animationSpeed: AnimationSpeed = .normal + public var startupAnimation = true + public var showsFavoriteBadge = true + public var showsClock = true + + public init() {} + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let defaults = ProfilePreferences() + func value(_ key: CodingKeys, _ fallback: T) -> T { + (try? container.decodeIfPresent(T.self, forKey: key)) ?? fallback + } + homeStyle = value(.homeStyle, defaults.homeStyle) + tileSize = value(.tileSize, defaults.tileSize) + startShelf = value(.startShelf, defaults.startShelf) + fontStyle = value(.fontStyle, defaults.fontStyle) + textSize = value(.textSize, defaults.textSize) + ambientBackground = value(.ambientBackground, defaults.ambientBackground) + animationLevel = value(.animationLevel, defaults.animationLevel) + animationSpeed = value(.animationSpeed, defaults.animationSpeed) + startupAnimation = value(.startupAnimation, defaults.startupAnimation) + showsFavoriteBadge = value(.showsFavoriteBadge, defaults.showsFavoriteBadge) + showsClock = value(.showsClock, defaults.showsClock) + } +} diff --git a/Sources/RetroXCore/Support/FileSystemProbing.swift b/Sources/RetroXCore/Support/FileSystemProbing.swift new file mode 100644 index 0000000..a0fd3ab --- /dev/null +++ b/Sources/RetroXCore/Support/FileSystemProbing.swift @@ -0,0 +1,57 @@ +import Foundation + +public enum FileItemKind: Sendable, Equatable { + case missing + case regularFile + case directory +} + +/// Leituras de sistema de arquivos usadas na validação. Existe para os testes trocarem o disco por um fake. +public protocol FileSystemProbing: Sendable { + /// Segue symlinks, como o emulador fará ao abrir o caminho. + func kind(of url: URL) -> FileItemKind + func isReadable(_ url: URL) -> Bool + func isExecutable(_ url: URL) -> Bool + func readPrefix(of url: URL, maxLength: Int) throws -> Data + /// Itens visíveis de uma pasta, em ordem de nome. Vazio se a pasta não existe ou não pode ser lida. + func contentsOfDirectory(at url: URL) -> [URL] +} + +extension FileSystemProbing { + func readText(of url: URL, maxLength: Int = 1 << 20) -> String? { + (try? readPrefix(of: url, maxLength: maxLength)).map { String(decoding: $0, as: UTF8.self) } + } +} + +public struct LocalFileSystem: FileSystemProbing { + public init() {} + + public func kind(of url: URL) -> FileItemKind { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false), isDirectory: &isDirectory) else { + return .missing + } + return isDirectory.boolValue ? .directory : .regularFile + } + + public func isReadable(_ url: URL) -> Bool { + FileManager.default.isReadableFile(atPath: url.path(percentEncoded: false)) + } + + public func isExecutable(_ url: URL) -> Bool { + FileManager.default.isExecutableFile(atPath: url.path(percentEncoded: false)) + } + + public func readPrefix(of url: URL, maxLength: Int) throws -> Data { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + return try handle.read(upToCount: maxLength) ?? Data() + } + + public func contentsOfDirectory(at url: URL) -> [URL] { + let items = (try? FileManager.default.contentsOfDirectory( + at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] + )) ?? [] + return items.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } + } +} diff --git a/Sources/RetroXCore/Support/INIFile.swift b/Sources/RetroXCore/Support/INIFile.swift new file mode 100644 index 0000000..cf24369 --- /dev/null +++ b/Sources/RetroXCore/Support/INIFile.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Leitor mínimo de INI (só leitura), no formato que o PCSX2 grava: `[Seção]` e `chave = valor`. +/// Chaves podem se repetir (por exemplo, várias `RecursivePaths`); a ordem é preservada. +public struct INIFile: Sendable, Equatable { + struct Entry: Sendable, Equatable { + let section: String + let key: String + let value: String + } + + private let entries: [Entry] + + public init(_ text: String) { + var section = "" + var entries: [Entry] = [] + for rawLine in text.split(whereSeparator: \.isNewline) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if line.isEmpty || line.hasPrefix(";") || line.hasPrefix("#") { continue } + if line.hasPrefix("["), line.hasSuffix("]") { + section = String(line.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces) + continue + } + guard let equals = line.firstIndex(of: "=") else { continue } + let key = line[.. [String] { + entries + .filter { $0.section.caseInsensitiveCompare(section) == .orderedSame && $0.key.caseInsensitiveCompare(key) == .orderedSame } + .map(\.value) + } + + /// O último valor não vazio da chave, como o próprio PCSX2 faria ao reler o arquivo. + public func value(_ key: String, in section: String) -> String? { + values(key, in: section).last { !$0.isEmpty } + } +} diff --git a/Sources/RetroXCore/Support/ImageImporter.swift b/Sources/RetroXCore/Support/ImageImporter.swift new file mode 100644 index 0000000..283678e --- /dev/null +++ b/Sources/RetroXCore/Support/ImageImporter.swift @@ -0,0 +1,29 @@ +import Foundation +import ImageIO + +public enum ImageImporterError: Error, Equatable { + case unreadableImage + case writeFailed +} + +/// Copia uma imagem escolhida pelo usuário (foto de perfil, capa) como PNG reduzido para dentro do RetroX. +/// O arquivo original nunca é alterado nem movido. +public enum ImageImporter { + public static func importPNG(from source: URL, to destination: URL, maxPixelSize: Int) throws { + guard let image = CoverThumbnailer.thumbnail(of: source, maxPixelSize: maxPixelSize) else { + throw ImageImporterError.unreadableImage + } + try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + let temporary = destination.deletingLastPathComponent().appending(path: ".\(UUID().uuidString).png") + guard let output = CGImageDestinationCreateWithURL(temporary as CFURL, "public.png" as CFString, 1, nil) else { + throw ImageImporterError.writeFailed + } + CGImageDestinationAddImage(output, image, nil) + guard CGImageDestinationFinalize(output) else { throw ImageImporterError.writeFailed } + // Grava num temporário e só então troca: uma falha no meio não deixa uma imagem pela metade. + if FileManager.default.fileExists(atPath: destination.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: destination) + } + try FileManager.default.moveItem(at: temporary, to: destination) + } +} diff --git a/Sources/RetroXCore/Support/JSONFileStore.swift b/Sources/RetroXCore/Support/JSONFileStore.swift new file mode 100644 index 0000000..40af40d --- /dev/null +++ b/Sources/RetroXCore/Support/JSONFileStore.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Configurações pequenas do RetroX em JSON (plugins ativos, favoritos, histórico). +/// A biblioteca completa vai para o SwiftData na Fase 2 (ADR-0005); isto cobre o que precisa sobreviver até lá. +public struct JSONFileStore: Sendable { + public let url: URL + + public init(url: URL) { + self.url = url + } + + /// `nil` se o arquivo não existe ou não pôde ser lido; quem chama decide o valor padrão. + public func load() -> Value? { + guard let data = try? Data(contentsOf: url) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(Value.self, from: data) + } + + public func save(_ value: Value) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(value).write(to: url, options: .atomic) + } +} + +public enum RetroXDirectories { + public static var applicationSupport: URL { + URL.applicationSupportDirectory.appending(path: "RetroX", directoryHint: .isDirectory) + } +} diff --git a/Sources/RetroXCore/Support/Log.swift b/Sources/RetroXCore/Support/Log.swift new file mode 100644 index 0000000..018df89 --- /dev/null +++ b/Sources/RetroXCore/Support/Log.swift @@ -0,0 +1,9 @@ +import os + +/// Caminhos e argumentos são sempre registrados como `.private` (ADR-0009). +enum Log { + static let subsystem = "local.retrox.RetroX" + static let launch = Logger(subsystem: subsystem, category: "launch") + /// Intervalo do North Star do PRD: do Play até o processo do emulador existir. + static let launchSignposter = OSSignposter(subsystem: subsystem, category: "launch") +} diff --git a/Sources/RetroXCore/Support/RunningApplications.swift b/Sources/RetroXCore/Support/RunningApplications.swift new file mode 100644 index 0000000..e040970 --- /dev/null +++ b/Sources/RetroXCore/Support/RunningApplications.swift @@ -0,0 +1,28 @@ +import AppKit + +public protocol RunningApplicationsProviding: Sendable { + func isRunning(bundleIdentifier: String) -> Bool + @discardableResult + func activate(bundleIdentifier: String) -> Bool +} + +public struct WorkspaceRunningApplications: RunningApplicationsProviding { + public init() {} + + public func isRunning(bundleIdentifier: String) -> Bool { + Self.alive(bundleIdentifier).first != nil + } + + @discardableResult + public func activate(bundleIdentifier: String) -> Bool { + Self.alive(bundleIdentifier).first?.activate() ?? false + } + + /// Logo depois que um app fecha, o LaunchServices ainda o lista por um instante; o processo, não. + /// Sem esse filtro, jogar de novo logo após fechar o emulador daria "já aberto". + private static func alive(_ bundleIdentifier: String) -> [NSRunningApplication] { + NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).filter { + !$0.isTerminated && kill($0.processIdentifier, 0) == 0 + } + } +} diff --git a/Support/Info.plist b/Support/Info.plist new file mode 100644 index 0000000..0376ff0 --- /dev/null +++ b/Support/Info.plist @@ -0,0 +1,37 @@ + + + + + CFBundleDevelopmentRegion + pt-BR + CFBundleDisplayName + RetroX + CFBundleExecutable + RetroX + CFBundleIconFile + RetroX + CFBundleIdentifier + + local.retrox.RetroX + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + RetroX + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.games + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSHumanReadableCopyright + © 2026 Otávio Luque. Licenciado sob a GPL-3.0. + NSPrincipalClass + NSApplication + + diff --git a/Support/RetroX.icns b/Support/RetroX.icns new file mode 100644 index 0000000..3927cfd Binary files /dev/null and b/Support/RetroX.icns differ diff --git a/Tests/RetroXCoreTests/AppBundleInspectorTests.swift b/Tests/RetroXCoreTests/AppBundleInspectorTests.swift new file mode 100644 index 0000000..f8e0f40 --- /dev/null +++ b/Tests/RetroXCoreTests/AppBundleInspectorTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing +@testable import RetroXCore + +private let recognizedHeaders: [(Data, Set)] = [ + (MachOSamples.thinX86_64, [.x86_64]), + (MachOSamples.thinARM64, [.arm64]), + (MachOSamples.universal, [.x86_64, .arm64]), + (MachOSamples.universal64, [.arm64]), +] + +struct MachOHeaderTests { + @Test("Lê arquiteturas de cabeçalhos thin e universais", arguments: recognizedHeaders) + func readsArchitectures(header: Data, expected: Set) { + #expect(MachOHeader.architectures(in: header) == expected) + } + + @Test("Não reconhece script, .class Java, dados truncados ou vazios", arguments: [ + MachOSamples.shellScript, MachOSamples.javaClass, Data([0xCF, 0xFA, 0xED]), Data(), + MachOSamples.universal.prefix(12), + ]) + func rejectsUnknownFormats(header: Data) { + #expect(MachOHeader.architectures(in: header).isEmpty) + } + + @Test("Funciona com Data fatiada (índice inicial diferente de zero)") + func handlesSlices() { + let padded = Data([0xFF, 0xFF]) + MachOSamples.thinARM64 + #expect(MachOHeader.architectures(in: padded.dropFirst(2)) == [.arm64]) + } +} + +struct AppBundleInspectorTests { + @Test("Lê executável, bundle ID, versão e arquitetura") + func inspectsBundle() throws { + var disk = FakeFileSystem() + disk.addAppBundle(at: "/Apps/PCSX2.app", executable: "PCSX2") + let info = try AppBundleInspector(fileSystem: disk).inspect(URL(filePath: "/Apps/PCSX2.app/")) + #expect(info.executableURL.path(percentEncoded: false) == "/Apps/PCSX2.app/Contents/MacOS/PCSX2") + #expect(info.bundleIdentifier == "net.pcsx2.pcsx2") + #expect(info.version == "v2.9.144") + #expect(info.architectures == [.x86_64]) + } + + @Test("App inexistente") + func missingApp() { + #expect(throws: LaunchError.emulatorNotFound) { + try AppBundleInspector(fileSystem: FakeFileSystem()).inspect(URL(filePath: "/Apps/Nada.app")) + } + } + + @Test("Arquivo comum no lugar do .app") + func plainFile() { + var disk = FakeFileSystem() + disk["/Apps/PCSX2.app"] = .file() + #expect(throws: LaunchError.emulatorBundleInvalid) { + try AppBundleInspector(fileSystem: disk).inspect(URL(filePath: "/Apps/PCSX2.app")) + } + } + + @Test("Pasta sem Info.plist") + func missingInfoPlist() { + var disk = FakeFileSystem() + disk["/Apps/PCSX2.app"] = .directory + #expect(throws: LaunchError.emulatorBundleInvalid) { + try AppBundleInspector(fileSystem: disk).inspect(URL(filePath: "/Apps/PCSX2.app")) + } + } + + @Test("Info.plist aponta para executável que não existe") + func missingExecutable() { + var disk = FakeFileSystem() + disk.addAppBundle(at: "/Apps/PCSX2.app", executable: "PCSX2") + disk.entries["/Apps/PCSX2.app/Contents/MacOS/PCSX2"] = nil + #expect(throws: LaunchError.emulatorBundleInvalid) { + try AppBundleInspector(fileSystem: disk).inspect(URL(filePath: "/Apps/PCSX2.app")) + } + } + + @Test("Executável sem permissão de execução") + func notExecutable() { + var disk = FakeFileSystem() + disk.addAppBundle(at: "/Apps/PCSX2.app", executableIsExecutable: false) + #expect(throws: LaunchError.emulatorNotExecutable) { + try AppBundleInspector(fileSystem: disk).inspect(URL(filePath: "/Apps/PCSX2.app")) + } + } +} + +struct HostEnvironmentTests { + let appleSilicon = HostEnvironment(architecture: .arm64, isRosettaInstalled: true) + let appleSiliconWithoutRosetta = HostEnvironment(architecture: .arm64, isRosettaInstalled: false) + let intel = HostEnvironment(architecture: .x86_64, isRosettaInstalled: false) + + @Test("Compatibilidade por arquitetura do executável") + func compatibility() { + #expect(appleSilicon.compatibility(with: [.arm64]) == .native) + #expect(appleSilicon.compatibility(with: [.x86_64, .arm64]) == .native) + #expect(appleSilicon.compatibility(with: [.x86_64]) == .rosetta) + #expect(appleSiliconWithoutRosetta.compatibility(with: [.x86_64]) == .incompatible(rosettaMissing: true)) + #expect(intel.compatibility(with: [.x86_64]) == .native) + #expect(intel.compatibility(with: [.arm64]) == .incompatible(rosettaMissing: false)) + #expect(appleSilicon.compatibility(with: []) == .unknown) + } +} diff --git a/Tests/RetroXCoreTests/GameLauncherTests.swift b/Tests/RetroXCoreTests/GameLauncherTests.swift new file mode 100644 index 0000000..b245e89 --- /dev/null +++ b/Tests/RetroXCoreTests/GameLauncherTests.swift @@ -0,0 +1,160 @@ +import Foundation +import Testing +@testable import RetroXCore + +struct GameLauncherPrepareTests { + @Test("Monta o plano do PCSX2 e avisa que roda via Rosetta") + func preparesPlan() throws { + let scenario = LaunchScenario() + let prepared = try scenario.launcher().prepare(scenario.request) + #expect(prepared.plan.arguments == ["-batch", "--", scenario.romPath]) + #expect(prepared.plan.executableURL.path(percentEncoded: false) == "/Apps/PCSX2.app/Contents/MacOS/PCSX2") + #expect(prepared.plan.bundleIdentifier == "net.pcsx2.pcsx2") + #expect(prepared.plan.strategy == .workspace) + #expect(prepared.plan.activates) + #expect(!prepared.plan.createsNewInstance) + #expect(prepared.warnings == [.runsUnderRosetta]) + } + + @Test("Estratégia do perfil prevalece sobre a do adapter") + func profileStrategyWins() throws { + var scenario = LaunchScenario() + scenario.profile.strategy = .process + #expect(try scenario.launcher().prepare(scenario.request).plan.strategy == .process) + } + + @Test("Extensão em maiúsculas é aceita") + func uppercaseExtension() throws { + var scenario = LaunchScenario() + scenario.romPath = "/Jogos/GT4.ISO" + scenario.disk[scenario.romPath] = .file() + #expect(try scenario.launcher().prepare(scenario.request).plan.arguments.last == "/Jogos/GT4.ISO") + } + + @Test("Universal nativo não gera aviso") + func nativeUniversal() throws { + var scenario = LaunchScenario() + scenario.disk.addAppBundle(at: scenario.appPath, executable: "PCSX2", header: MachOSamples.universal) + #expect(try scenario.launcher().prepare(scenario.request).warnings.isEmpty) + } + + @Test("Bundle ID inesperado e arquitetura desconhecida viram avisos, não erros") + func softWarnings() throws { + var scenario = LaunchScenario() + scenario.disk.addAppBundle( + at: scenario.appPath, executable: "PCSX2", bundleIdentifier: "org.exemplo.outro", header: MachOSamples.shellScript + ) + let warnings = try scenario.launcher().prepare(scenario.request).warnings + #expect(warnings == [.unexpectedBundleIdentifier("org.exemplo.outro"), .unknownArchitecture]) + } + + @Test("Emulador já aberto: recusa por padrão") + func refusesWhenRunning() { + var scenario = LaunchScenario() + scenario.running.running = ["net.pcsx2.pcsx2"] + #expect(throws: LaunchError.emulatorAlreadyRunning(bundleID: "net.pcsx2.pcsx2")) { + try scenario.launcher().prepare(scenario.request) + } + } + + @Test("Emulador já aberto: nova instância só se pedida") + func newInstanceWhenAsked() throws { + var scenario = LaunchScenario() + scenario.running.running = ["net.pcsx2.pcsx2"] + var request = scenario.request + request.alreadyRunningPolicy = .launchNewInstance + #expect(try scenario.launcher().prepare(request).plan.createsNewInstance) + } + + @Test("Erros de ROM") + func romErrors() { + var scenario = LaunchScenario() + scenario.disk[scenario.romPath] = nil + #expect(throws: LaunchError.romNotFound) { try scenario.launcher().prepare(scenario.request) } + + scenario.disk[scenario.romPath] = .directory + #expect(throws: LaunchError.romIsDirectory) { try scenario.launcher().prepare(scenario.request) } + + scenario.disk[scenario.romPath] = .file(readable: false) + #expect(throws: LaunchError.romUnreadable) { try scenario.launcher().prepare(scenario.request) } + + scenario.romPath = "/Jogos/jogo.zip" + scenario.disk[scenario.romPath] = .file() + #expect(throws: LaunchError.unsupportedFormat("zip")) { try scenario.launcher().prepare(scenario.request) } + } + + @Test("Erros de emulador") + func emulatorErrors() { + var scenario = LaunchScenario() + scenario.profile.appURL = URL(filePath: "/Apps/Inexistente.app") + #expect(throws: LaunchError.emulatorNotFound) { try scenario.launcher().prepare(scenario.request) } + + scenario = LaunchScenario() + scenario.profile.adapterID = EmulatorAdapterID(rawValue: "desconhecido") + #expect(throws: LaunchError.emulatorNotConfigured(.ps2)) { try scenario.launcher().prepare(scenario.request) } + + scenario = LaunchScenario() + let wii = GameSystemID(rawValue: "wii") + var request = scenario.request + request.systemID = wii + #expect(throws: LaunchError.emulatorDoesNotSupportSystem(wii)) { try scenario.launcher().prepare(request) } + + scenario = LaunchScenario() + scenario.profile.extraArguments = ["-batch"] + #expect(throws: LaunchError.invalidExtraArgument("-batch")) { try scenario.launcher().prepare(scenario.request) } + } + + @Test("Arquitetura: sem Rosetta e Intel com app só arm64") + func architectureErrors() { + var scenario = LaunchScenario() + scenario.host.isRosettaInstalled = false + #expect(throws: LaunchError.architectureUnsupported(rosettaMissing: true)) { + try scenario.launcher().prepare(scenario.request) + } + + scenario = LaunchScenario() + scenario.host = HostEnvironment(architecture: .x86_64, isRosettaInstalled: false) + scenario.disk.addAppBundle(at: scenario.appPath, executable: "PCSX2", header: MachOSamples.thinARM64) + #expect(throws: LaunchError.architectureUnsupported(rosettaMissing: false)) { + try scenario.launcher().prepare(scenario.request) + } + } +} + +struct GameLauncherLaunchTests { + @Test("Entrega ao launcher exatamente o plano validado") + func launchesPreparedPlan() async throws { + let scenario = LaunchScenario() + let recording = RecordingLauncher() + let launcher = scenario.launcher(recording: recording) + let prepared = try launcher.prepare(scenario.request) + let launched = try await launcher.launch(prepared) + #expect(launched.pid == 4242) + #expect(await recording.plans == [prepared.plan]) + #expect(await launched.waitForExit()?.status == 0) + } + + @Test("Estratégia sem launcher registrado falha com mensagem") + func missingStrategy() async throws { + let scenario = LaunchScenario() + let launcher = GameLauncher( + fileSystem: scenario.disk, host: scenario.host, runningApplications: scenario.running, launchers: [:] + ) + let prepared = try launcher.prepare(scenario.request) + await #expect(throws: LaunchError.self) { try await launcher.launch(prepared) } + } +} + +struct SessionOutcomeTests { + let start = Date(timeIntervalSince1970: 1_000) + + @Test("Saída antes de 5 s é falha provável; depois é sessão normal") + func classifies() { + #expect(SessionOutcome.classify(launchedAt: start, exit: ProcessExit(status: 1, endedAt: start + 1.5)) + == .exitedEarly(status: 1, after: .milliseconds(1500))) + #expect(SessionOutcome.classify(launchedAt: start, exit: ProcessExit(status: nil, endedAt: start + 600)) + == .ended(after: .seconds(600))) + #expect(SessionOutcome.classify(launchedAt: start, exit: ProcessExit(status: 0, endedAt: start + 5)) + == .ended(after: .seconds(5))) + } +} diff --git a/Tests/RetroXCoreTests/InstalledEmulatorTests.swift b/Tests/RetroXCoreTests/InstalledEmulatorTests.swift new file mode 100644 index 0000000..c147d25 --- /dev/null +++ b/Tests/RetroXCoreTests/InstalledEmulatorTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import RetroXCore + +/// Opt-in e somente leitura. Não abre emuladores nem acessa jogos/configurações pessoais. +struct InstalledEmulatorTests { + @Test("Instalação real do PCSX2 e preparo de launch", .enabled(if: path("RX_PCSX2_APP") != nil)) + func pcsx2() throws { + let app = try inspect("RX_PCSX2_APP") + let folder = try TemporaryFolder() + let rom = try folder.file("Jogos Ação 日本/-Teste (USA).iso") + let profile = EmulatorProfile(name: "PCSX2", adapterID: .pcsx2, appURL: app.bundleURL, options: PCSX2Adapter().defaultOptions) + let prepared = try GameLauncher(runningApplications: FakeRunningApplications()).prepare( + LaunchRequest(romURL: rom, systemID: .ps2, profile: profile) + ) + #expect(prepared.plan.executableURL == app.executableURL) + #expect(prepared.plan.arguments == ["-batch", "--", rom.path(percentEncoded: false)]) + #expect(!prepared.warnings.contains { if case .unexpectedBundleIdentifier = $0 { true } else { false } }) + } + + @Test("Inspeção do bundle instalado do Azahar", .enabled(if: path("RX_AZAHAR_APP") != nil)) + func azahar() throws { _ = try inspect("RX_AZAHAR_APP") } + + @Test("Inspeção do bundle instalado do Ryujinx", .enabled(if: path("RX_RYUJINX_APP") != nil)) + func ryujinx() throws { _ = try inspect("RX_RYUJINX_APP") } + + private static func path(_ key: String) -> String? { + ProcessInfo.processInfo.environment[key].flatMap { $0.isEmpty ? nil : $0 } + } + + private func inspect(_ key: String) throws -> AppBundleInfo { + let path = try #require(Self.path(key)) + let app = try AppBundleInspector().inspect(URL(filePath: path)) + #expect(app.bundleIdentifier?.isEmpty == false) + #expect(!app.architectures.isEmpty) + switch HostEnvironment.current().compatibility(with: app.architectures) { + case .native, .rosetta: break + case .unknown, .incompatible: Issue.record("Arquitetura incompatível com este Mac: \(app.architectures)") + } + return app + } +} diff --git a/Tests/RetroXCoreTests/LaunchIntegrationTests.swift b/Tests/RetroXCoreTests/LaunchIntegrationTests.swift new file mode 100644 index 0000000..37ff79a --- /dev/null +++ b/Tests/RetroXCoreTests/LaunchIntegrationTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import RetroXCore + +/// Abre de verdade um emulador falso, pelo LaunchServices e por exec direto, e confere o argv recebido. +/// Opt-in: `scripts/integration-launch.sh`. Não toca em emuladores nem em ROMs reais. +@Suite(.serialized, .enabled(if: FakeEmulatorBundle.binaryURL != nil)) +struct LaunchIntegrationTests { + /// Pasta com NFD, acentos, CJK e emoji; arquivo começando com hífen e com caracteres de shell. + static let folder = "Jogos Ac\u{0327}a\u{0303}o ñ 日本 🎮" + static let file = "-Teste $(echo x); 'q' \"a\" (USA).iso" + + @Test("argv chega byte a byte", arguments: LaunchStrategy.allCases) + func argvArrivesIntact(strategy: LaunchStrategy) async throws { + let fixture = try FakeEmulatorBundle() + defer { fixture.remove() } + let romURL = try fixture.makeROM(named: Self.file, inFolder: Self.folder) + + let launcher = GameLauncher() + let prepared = try launcher.prepare(request(romURL: romURL, appURL: fixture.appURL, strategy: strategy)) + #expect(prepared.warnings == [.unexpectedBundleIdentifier(FakeEmulatorBundle.bundleIdentifier)]) + + let launched = try await launcher.launch(prepared) + let received = try await fixture.waitForArguments(pid: launched.pid) + #expect(received.map { Array($0.utf8) } == prepared.plan.arguments.map { Array($0.utf8) }) + #expect(received.last.map { FileManager.default.fileExists(atPath: $0) } == true) + + let exit = await launched.waitForExit() + #expect(exit != nil) + if strategy == .process { + #expect(exit?.status == 0) + } + } + + @Test("Com o app aberto, o LaunchServices reaproveita a instância e não entrega os novos argumentos") + func workspaceReusesRunningInstance() async throws { + let fixture = try FakeEmulatorBundle() + defer { fixture.remove() } + let firstROM = try fixture.makeROM(named: "Primeiro.iso", inFolder: "roms") + let secondROM = try fixture.makeROM(named: "Segundo.iso", inFolder: "roms") + + let launcher = GameLauncher() + let first = try await launcher.launch(launcher.prepare(request(romURL: firstROM, appURL: fixture.appURL))) + _ = try await fixture.waitForArguments(pid: first.pid) + + // A validação já recusa o segundo launch... + #expect(throws: LaunchError.emulatorAlreadyRunning(bundleID: FakeEmulatorBundle.bundleIdentifier)) { + try launcher.prepare(request(romURL: secondROM, appURL: fixture.appURL)) + } + + // ...e este é o motivo: sem nova instância, o sistema só ativa o app que já está aberto. + let firstPlan = first.plan + let secondPlan = LaunchPlan( + appURL: firstPlan.appURL, + executableURL: firstPlan.executableURL, + bundleIdentifier: firstPlan.bundleIdentifier, + arguments: ["-batch", "--", secondROM.path(percentEncoded: false)], + strategy: .workspace, + activates: true, + createsNewInstance: false + ) + let second = try await WorkspaceLauncher().launch(secondPlan) + #expect(second.pid == first.pid) + try await Task.sleep(for: .seconds(1)) + #expect(try fixture.argumentFiles().count == 1) + + _ = await first.waitForExit() + } + + private func request(romURL: URL, appURL: URL, strategy: LaunchStrategy = .workspace) -> LaunchRequest { + let profile = EmulatorProfile( + name: "Fake", adapterID: .pcsx2, appURL: appURL, options: PCSX2Adapter().defaultOptions, strategy: strategy + ) + return LaunchRequest(romURL: romURL, systemID: .ps2, profile: profile) + } +} diff --git a/Tests/RetroXCoreTests/LibraryTests.swift b/Tests/RetroXCoreTests/LibraryTests.swift new file mode 100644 index 0000000..7eb525e --- /dev/null +++ b/Tests/RetroXCoreTests/LibraryTests.swift @@ -0,0 +1,168 @@ +import Foundation +import Testing +@testable import RetroXCore + +/// Pasta temporária real, apagada ao fim do teste. +final class TemporaryFolder { + let url: URL + + init() throws { + url = FileManager.default.temporaryDirectory.appending(path: "RetroXTests-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + deinit { + try? FileManager.default.removeItem(at: url) + } + + @discardableResult + func file(_ relativePath: String, bytes: Int = 0) throws -> URL { + let fileURL = url.appending(path: relativePath, directoryHint: .notDirectory) + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(count: bytes).write(to: fileURL) + return fileURL + } +} + +struct GameScannerTests { + @Test("Varre recursivamente, ignora caixa da extensão, ocultos e formatos não suportados") + func scans() throws { + let folder = try TemporaryFolder() + try folder.file("roms/Jogo A (USA).iso", bytes: 10) + try folder.file("roms/sub/Jogo B.CHD", bytes: 20) + try folder.file("roms/.oculto.iso") + try folder.file("roms/leia-me.txt") + try folder.file("roms/jogo.zip") + let roms = GameScanner.scan( + [ROMDirectory(url: folder.url.appending(path: "roms"), origin: .rootFolder, isRecursive: true)], + extensions: ["iso", "chd"] + ) + #expect(roms.map(\.url.lastPathComponent).sorted() == ["Jogo A (USA).iso", "Jogo B.CHD"]) + #expect(roms.first { $0.url.lastPathComponent == "Jogo B.CHD" }?.fileSize == 20) + } + + @Test("Pasta não recursiva não desce em subpastas") + func shallow() throws { + let folder = try TemporaryFolder() + try folder.file("A.iso") + try folder.file("sub/B.iso") + let roms = GameScanner.scan([ROMDirectory(url: folder.url, origin: .rootFolder, isRecursive: false)], extensions: ["iso"]) + #expect(roms.map(\.url.lastPathComponent) == ["A.iso"]) + } + + @Test("O mesmo arquivo por duas pastas sobrepostas ou por symlink conta uma vez") + func deduplicates() throws { + let folder = try TemporaryFolder() + let original = try folder.file("roms/A.iso") + try FileManager.default.createDirectory(at: folder.url.appending(path: "atalhos"), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: folder.url.appending(path: "atalhos/A.iso"), withDestinationURL: original) + let roms = GameScanner.scan([ + ROMDirectory(url: folder.url, origin: .rootFolder, isRecursive: true), + ROMDirectory(url: folder.url.appending(path: "roms"), origin: .emulatorSettings, isRecursive: true), + ], extensions: ["iso"]) + #expect(roms.count == 1) + } + + @Test("Não altera nada nas pastas de jogos") + func readOnly() throws { + let folder = try TemporaryFolder() + let rom = try folder.file("roms/A.iso", bytes: 4) + let before = try FileManager.default.attributesOfItem(atPath: rom.path(percentEncoded: false)) + _ = GameScanner.scan([ROMDirectory(url: folder.url, origin: .rootFolder, isRecursive: true)], extensions: ["iso"]) + let after = try FileManager.default.attributesOfItem(atPath: rom.path(percentEncoded: false)) + #expect(before[.modificationDate] as? Date == after[.modificationDate] as? Date) + #expect(try FileManager.default.contentsOfDirectory(atPath: folder.url.appending(path: "roms").path(percentEncoded: false)) == ["A.iso"]) + } +} + +struct LibraryBuilderTests { + @Test("Monta jogos do plugin com título, sistema e capa") + func buildsGames() throws { + let folder = try TemporaryFolder() + try folder.file("roms/GT 4 Spec II v1.08.iso") + try folder.file("roms/Test Drive Unlimited (USA).iso") + let cover = try folder.file("covers/gt4-cover.png") + var discovery = PluginDiscovery(root: folder.url) + discovery.romDirectories = [ROMDirectory(url: folder.url.appending(path: "roms"), origin: .rootFolder, isRecursive: true)] + discovery.coverDirectories = [folder.url.appending(path: "covers")] + + let games = LibraryBuilder.games(for: PCSX2Plugin(), discovery: discovery) + #expect(games.map(\.title) == ["GT 4 Spec II v1.08", "Test Drive Unlimited (USA)"]) + #expect(games.allSatisfy { $0.systemID == .ps2 && $0.pluginID == .pcsx2 }) + #expect(games[0].coverURL?.lastPathComponent == cover.lastPathComponent) + #expect(games[1].coverURL == nil) + } +} + +struct CoverMatcherTests { + let covers = [ + "gt4-cover.png", "Test Drive Unlimited (USA).jpg", "god of war front.webp", "x.png", "notas.txt", + ].map { URL(filePath: "/capas/\($0)") } + + @Test("Associações esperadas", arguments: [ + ("GT 4 Spec II v1.08.iso", "gt4-cover.png"), + ("Test Drive Unlimited (USA).iso", "Test Drive Unlimited (USA).jpg"), + ("God of War (USA).chd", "god of war front.webp"), + ("Gran Turismo 3.iso", nil), + ("X.iso", nil), + ] as [(String, String?)]) + func matches(rom: String, expected: String?) { + #expect(CoverMatcher.match(romFilename: rom, covers: covers)?.lastPathComponent == expected) + } + + @Test("Nome idêntico vence prefixo") + func exactWins() { + let covers = ["Jogo.png", "Jogo 2.png"].map { URL(filePath: "/c/\($0)") } + #expect(CoverMatcher.match(romFilename: "Jogo 2.iso", covers: covers)?.lastPathComponent == "Jogo 2.png") + } + + @Test("Capas preparadas preservam prioridade e ordem em empates") + func preparedCoverPriority() { + let covers = ["Jogo extra.png", "Jogo outro.jpg", "SLUS-12345.png", "slus-12345.jpg", "Jogo.png"] + .map { URL(filePath: "/c/\($0)") } + let prepared = CoverMatcher.PreparedCovers(covers) + #expect(prepared.match(romFilename: "Jogo.iso", serial: "slus-12345") == covers[2]) + #expect(prepared.match(romFilename: "Jogo.iso") == covers[4]) + #expect(CoverMatcher.PreparedCovers(Array(covers.prefix(2))).match(romFilename: "Jogo.iso") == covers[0]) + } +} + +struct JSONFileStoreTests { + @Test("Salva e relê; arquivo ausente devolve nil") + func roundTrip() throws { + let folder = try TemporaryFolder() + let store = JSONFileStore<[String: GameHistory]>(url: folder.url.appending(path: "sub/historico.json")) + #expect(store.load() == nil) + let value = ["/a.iso": GameHistory(isFavorite: true, lastPlayedAt: Date(timeIntervalSince1970: 1_000_000), playCount: 2)] + try store.save(value) + #expect(store.load() == value) + } +} + +struct PaletteTests { + private func check(_ first: Tone, _ second: Tone, atLeast minimum: Double) { + #expect(HexColor.contrast(first.light, second.light) >= minimum) + #expect(HexColor.contrast(first.dark, second.dark) >= minimum) + } + + @Test("Texto sobre fundos atinge 4,5:1 nos dois modos") + func textContrast() { + for background in [Palette.background, Palette.surface, Palette.raised] { + check(Palette.textPrimary, background, atLeast: 4.5) + check(Palette.textSecondary, background, atLeast: 4.5) + } + } + + @Test("Acentos: texto sobre o preenchimento 4,5:1; anel de foco sobre o fundo 3:1", arguments: Palette.Accent.allCases) + func accentContrast(accent: Palette.Accent) { + check(accent.onFill, accent.fill, atLeast: 4.5) + check(accent.fill, Palette.background, atLeast: 3) + check(accent.fill, Palette.surface, atLeast: 3) + } + + @Test("Texto das capas geradas é legível") + func coverContrast() { + check(Palette.onCover, Palette.coverTone(for: .ps2), atLeast: 4.5) + check(Palette.onCover, Palette.coverTone(for: GameSystemID(rawValue: "outro")), atLeast: 4.5) + } +} diff --git a/Tests/RetroXCoreTests/MetadataNetworkTests.swift b/Tests/RetroXCoreTests/MetadataNetworkTests.swift new file mode 100644 index 0000000..17dbdfc --- /dev/null +++ b/Tests/RetroXCoreTests/MetadataNetworkTests.swift @@ -0,0 +1,18 @@ +import Foundation +import Testing +@testable import RetroXCore + +/// Consulta a Wikipedia de verdade. Opt-in: `RX_NETWORK=1 scripts/test.sh --filter MetadataNetworkTests`. +@Suite(.enabled(if: ProcessInfo.processInfo.environment["RX_NETWORK"] == "1")) +struct MetadataNetworkTests { + @Test("Busca e resumo reais na Wikipedia") + func liveWikipedia() async throws { + let provider = WikipediaMetadataProvider() + let results = try await provider.search("Test Drive Unlimited") + let first = try #require(results.first) + #expect(first.title.contains("Test Drive")) + let metadata = try await provider.metadata(for: first) + #expect(!metadata.summary.isEmpty) + #expect(metadata.sourceURL != nil) + } +} diff --git a/Tests/RetroXCoreTests/MockedLaunchTests.swift b/Tests/RetroXCoreTests/MockedLaunchTests.swift new file mode 100644 index 0000000..4c0b3b0 --- /dev/null +++ b/Tests/RetroXCoreTests/MockedLaunchTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import RetroXCore + +private actor FailingLauncher: ProcessLaunching { + private(set) var plans: [LaunchPlan] = [] + let error: LaunchError + + init(_ error: LaunchError) { self.error = error } + + func launch(_ plan: LaunchPlan) async throws(LaunchError) -> LaunchedProcess { + plans.append(plan) + throw error + } +} + +struct MockedLaunchTests { + @Test("Só chama o executor selecionado, uma vez", arguments: LaunchStrategy.allCases) + func routesExactlyOnce(strategy: LaunchStrategy) async throws { + var scenario = LaunchScenario() + scenario.profile.strategy = strategy + let workspace = RecordingLauncher() + let process = RecordingLauncher() + let launcher = GameLauncher( + fileSystem: scenario.disk, host: scenario.host, runningApplications: scenario.running, + launchers: [.workspace: workspace, .process: process] + ) + let prepared = try launcher.prepare(scenario.request) + #expect(await workspace.plans.isEmpty) + #expect(await process.plans.isEmpty) + _ = try await launcher.launch(prepared) + #expect(await workspace.plans == (strategy == .workspace ? [prepared.plan] : [])) + #expect(await process.plans == (strategy == .process ? [prepared.plan] : [])) + } + + @Test("Falha do executor é propagada sem retry nem fallback") + func propagatesFailure() async throws { + let scenario = LaunchScenario() + let expected = LaunchError.launchFailed("falha simulada") + let failing = FailingLauncher(expected) + let fallback = RecordingLauncher() + let launcher = GameLauncher( + fileSystem: scenario.disk, host: scenario.host, runningApplications: scenario.running, + launchers: [.workspace: failing, .process: fallback] + ) + let prepared = try launcher.prepare(scenario.request) + await #expect(throws: expected) { try await launcher.launch(prepared) } + #expect(await failing.plans == [prepared.plan]) + #expect(await fallback.plans.isEmpty) + } + + @Test("Validação rejeitada não executa processos") + func invalidRequestDoesNotLaunch() async { + var scenario = LaunchScenario() + scenario.disk[scenario.romPath] = nil + let recording = RecordingLauncher() + let launcher = scenario.launcher(recording: recording) + await #expect(throws: LaunchError.romNotFound) { + let prepared = try launcher.prepare(scenario.request) + _ = try await launcher.launch(prepared) + } + #expect(await recording.plans.isEmpty) + } +} diff --git a/Tests/RetroXCoreTests/MockedMetadataTests.swift b/Tests/RetroXCoreTests/MockedMetadataTests.swift new file mode 100644 index 0000000..656bbd4 --- /dev/null +++ b/Tests/RetroXCoreTests/MockedMetadataTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import RetroXCore + +/// Respostas em ordem e registro de URLs, sem estado global compartilhado entre testes. +private actor ScriptedHTTP: HTTPFetching { + private var replies: [Result] + private(set) var requests: [URL] = [] + + init(_ replies: [Result]) { self.replies = replies } + + func data(from url: URL) async throws -> Data { + requests.append(url) + guard !replies.isEmpty else { throw MetadataError.invalidResponse } + return try replies.removeFirst().get() + } +} + +struct MockedMetadataTests { + private let found = Data(#"{"query":{"search":[{"title":"Jogo","snippet":"Descrição"}]}}"#.utf8) + private let empty = Data(#"{"query":{"search":[]}}"#.utf8) + + @Test("Resultado em português evita chamada em inglês") + func stopsAfterFirstResult() async throws { + let http = ScriptedHTTP([.success(found)]) + let results = try await WikipediaMetadataProvider(http: http).search("Jogo") + #expect(results.count == 1) + #expect(await http.requests.map(\.host) == ["pt.wikipedia.org"]) + } + + @Test("Erro em português permite recuperação em inglês") + func recoversAfterError() async throws { + let http = ScriptedHTTP([.failure(.http(503)), .success(found)]) + let results = try await WikipediaMetadataProvider(http: http).search("Jogo") + #expect(results.map(\.language) == ["en"]) + #expect(await http.requests.map(\.host) == ["pt.wikipedia.org", "en.wikipedia.org"]) + } + + @Test("JSON inválido permite recuperação no idioma seguinte") + func recoversAfterMalformedJSON() async throws { + let http = ScriptedHTTP([.success(Data("não é JSON".utf8)), .success(found)]) + #expect(try await WikipediaMetadataProvider(http: http).search("Jogo").first?.language == "en") + #expect(await http.requests.count == 2) + } + + @Test("Busca vazia não faz chamadas além dos idiomas configurados") + func emptyResults() async throws { + let http = ScriptedHTTP([.success(empty), .success(empty)]) + #expect(try await WikipediaMetadataProvider(http: http).search("Nada").isEmpty) + #expect(await http.requests.count == 2) + } + + @Test("Caracteres reservados ficam no parâmetro, sem alterar a requisição") + func escapesQuery() async throws { + let query = "Ação & 日本? #x=/🎮" + let http = ScriptedHTTP([.success(found)]) + _ = try await WikipediaMetadataProvider(http: http).search(query) + let url = try #require(await http.requests.first) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + #expect(components.queryItems?.first { $0.name == "srsearch" }?.value == "\(query) jogo eletrônico") + #expect(components.fragment == nil) + #expect(components.path == "/w/api.php") + } +} diff --git a/Tests/RetroXCoreTests/PCSX2AdapterTests.swift b/Tests/RetroXCoreTests/PCSX2AdapterTests.swift new file mode 100644 index 0000000..dd20f63 --- /dev/null +++ b/Tests/RetroXCoreTests/PCSX2AdapterTests.swift @@ -0,0 +1,91 @@ +import Testing +@testable import RetroXCore + +struct PCSX2AdapterTests { + let adapter = PCSX2Adapter() + + @Test("Padrão: -batch, --, caminho") + func defaultArguments() throws { + let arguments = try adapter.arguments(romPath: "/Jogos/GT4.iso", options: adapter.defaultOptions, extraArguments: []) + #expect(arguments == ["-batch", "--", "/Jogos/GT4.iso"]) + } + + @Test("Opções tipadas viram flags na ordem esperada") + func typedOptions() throws { + let options = PCSX2Options(batch: false, fullscreen: .on, bigPicture: true, boot: .fast) + let arguments = try adapter.arguments(romPath: "/j.iso", options: .pcsx2(options), extraArguments: ["-portable"]) + #expect(arguments == ["-fullscreen", "-bigpicture", "-fastboot", "-portable", "--", "/j.iso"]) + } + + @Test("Tela cheia e boot desligáveis") + func offVariants() throws { + let options = PCSX2Options(fullscreen: .off, boot: .slow) + let arguments = try adapter.arguments(romPath: "/j.iso", options: .pcsx2(options), extraArguments: []) + #expect(arguments == ["-batch", "-nofullscreen", "-slowboot", "--", "/j.iso"]) + } + + /// Caminhos que quebrariam uma linha de shell. Aqui cada um tem que chegar como um único elemento, byte a byte. + @Test("O caminho da ROM chega inteiro, byte a byte", arguments: [ + "/Volumes/Jogos Ação/Test Drive Unlimited (USA).iso", + "/Volumes/Jogos Ac\u{0327}a\u{0303}o/NFD (Brasil).iso", + "/Jogos/-começa-com-hifen.iso", + "/Jogos/a=b.iso", + "/Jogos/\"aspas\" e 'apóstrofo'.iso", + "/Jogos/$(rm -rf ~); `x` & | > <.iso", + "/Jogos/quebra\nde linha.iso", + "/Jogos/🎮 日本語 ñ.iso", + "/Jogos/" + String(repeating: "longo ", count: 200) + ".iso", + ]) + func romPathIsSingleExactElement(path: String) throws { + let arguments = try adapter.arguments(romPath: path, options: adapter.defaultOptions, extraArguments: []) + #expect(arguments.count == 3) + #expect(arguments[1] == "--") + // String do Swift compara por equivalência canônica (NFC == NFD); aqui a forma exata importa. + #expect(Array(arguments[2].utf8) == Array(path.utf8)) + } + + @Test("Recusa argumentos extras que colidem com flags gerenciadas", arguments: [ + "--", "-batch", "-fullscreen", "-elf", "-bios", "-help", "", "-x\0y", + ]) + func rejectsManagedExtraArguments(extra: String) { + #expect(throws: LaunchError.invalidExtraArgument(extra)) { + try adapter.arguments(romPath: "/j.iso", options: adapter.defaultOptions, extraArguments: [extra]) + } + } + + @Test("Aceita argumentos extras legítimos") + func acceptsUserExtraArguments() throws { + let arguments = try adapter.arguments( + romPath: "/j.iso", options: adapter.defaultOptions, extraArguments: ["-datapath", "/Dados PCSX2"] + ) + #expect(arguments == ["-batch", "-datapath", "/Dados PCSX2", "--", "/j.iso"]) + } + + @Test("Formatos do MVP só para PS2") + func supportedExtensions() { + #expect(adapter.supportedExtensions(for: .ps2) == ["iso", "chd", "cso", "zso"]) + #expect(adapter.supportedExtensions(for: GameSystemID(rawValue: "wii")).isEmpty) + } +} + +struct LaunchErrorTests { + @Test("Todo erro oferece ao menos uma ação de correção e uma mensagem", arguments: [ + LaunchError.emulatorNotConfigured(.ps2), .emulatorDoesNotSupportSystem(.ps2), .emulatorNotFound, + .emulatorBundleInvalid, .emulatorNotExecutable, .architectureUnsupported(rosettaMissing: true), + .architectureUnsupported(rosettaMissing: false), .romNotFound, .romIsDirectory, .romUnreadable, + .unsupportedFormat("zip"), .emulatorAlreadyRunning(bundleID: "net.pcsx2.pcsx2"), + .invalidExtraArgument("--"), .launchFailed("x"), .exitedEarly(status: 1, after: .seconds(1)), + ]) + func everyErrorIsActionable(error: LaunchError) { + #expect(!error.recoveryActions.isEmpty) + #expect(!error.message.isEmpty) + } + + @Test("Mapeamentos principais") + func keyMappings() { + #expect(LaunchError.romNotFound.recoveryActions == [.locateROM]) + #expect(LaunchError.emulatorAlreadyRunning(bundleID: "x").recoveryActions == [.bringEmulatorToFront]) + #expect(LaunchError.architectureUnsupported(rosettaMissing: true).recoveryActions.first == .showRosettaHelp) + #expect(LaunchError.emulatorNotConfigured(.ps2).message.contains("PS2")) + } +} diff --git a/Tests/RetroXCoreTests/PCSX2PluginTests.swift b/Tests/RetroXCoreTests/PCSX2PluginTests.swift new file mode 100644 index 0000000..9d83079 --- /dev/null +++ b/Tests/RetroXCoreTests/PCSX2PluginTests.swift @@ -0,0 +1,167 @@ +import Foundation +import Testing +@testable import RetroXCore + +/// Pasta raiz no formato "emulador + roms + capas + bios", com a configuração do PCSX2 fora dela. +struct PluginScenario { + var disk = FakeFileSystem() + let root = "/Emuladores/ps2" + let dataDirectory = "/Dados/PCSX2" + + init(settings: String? = PluginScenario.defaultSettings) { + for folder in [ + "/Emuladores", root, "\(root)/roms", "\(root)/covers", "\(root)/ps2 bios", + "/Outra", "/Outra/Jogos", "/Dados", dataDirectory, "\(dataDirectory)/inis", + ] { + disk[folder] = .directory + } + disk.addAppBundle(at: "\(root)/PCSX2-v2.9.144.app", executable: "PCSX2") + disk["\(root)/roms/Jogo (USA).iso"] = .file() + disk["\(root)/covers/jogo-cover.png"] = .file() + disk["\(root)/ps2 bios/SCPH-0001.bin"] = .file() + if let settings { + disk["\(dataDirectory)/inis/PCSX2.ini"] = .file(Data(settings.utf8)) + } + } + + static let defaultSettings = """ + [UI] + StartFullscreen = true + + [Folders] + Bios = ../../Emuladores/ps2/ps2 bios + + [Filenames] + BIOS = SCPH-0001.bin + + [GameList] + RecursivePaths = /Emuladores/ps2/roms + Paths = /Outra/Jogos + """ + + var plugin: PCSX2Plugin { + PCSX2Plugin(fileSystem: disk, defaultDataDirectory: URL(filePath: dataDirectory, directoryHint: .isDirectory)) + } + + func discover(_ path: String? = nil) -> PluginDiscovery { + plugin.discover(root: URL(filePath: path ?? root, directoryHint: .isDirectory)) + } +} + +private func path(_ url: URL) -> String { + url.standardizedFileURL.path(percentEncoded: false).trimmingSuffix("/") +} + +struct PCSX2PluginTests { + @Test("Encontra app, pastas de jogos, capas e BIOS a partir da pasta raiz e do PCSX2.ini") + func discoversEverything() { + let discovery = PluginScenario().discover() + #expect(discovery.issues.isEmpty) + #expect(discovery.isReady) + #expect(discovery.app?.bundleIdentifier == "net.pcsx2.pcsx2") + #expect(discovery.romDirectories.map { path($0.url) } == ["/Emuladores/ps2/roms", "/Outra/Jogos"]) + // A pasta "roms" aparece na raiz e no PCSX2.ini: fica uma vez só, recursiva, com a origem da raiz. + #expect(discovery.romDirectories[0].origin == .rootFolder) + #expect(discovery.romDirectories[0].isRecursive) + #expect(discovery.romDirectories[1] == ROMDirectory(url: discovery.romDirectories[1].url, origin: .emulatorSettings, isRecursive: false)) + #expect(discovery.coverDirectories.map(path) == ["/Emuladores/ps2/covers"]) + #expect(discovery.bios == .configured("SCPH-0001.bin")) + #expect(discovery.settingsFile.map(path) == "/Dados/PCSX2/inis/PCSX2.ini") + } + + @Test("Escolher o próprio .app usa a pasta que o contém como raiz") + func acceptsAppAsRoot() { + let discovery = PluginScenario().discover("/Emuladores/ps2/PCSX2-v2.9.144.app") + #expect(path(discovery.root) == "/Emuladores/ps2") + #expect(discovery.isReady) + } + + @Test("App numa subpasta da raiz também é encontrado") + func findsNestedApp() { + var scenario = PluginScenario() + for key in scenario.disk.entries.keys where key.hasPrefix("/Emuladores/ps2/PCSX2-v2.9.144.app") { + scenario.disk.entries[key] = nil + } + scenario.disk["/Emuladores/ps2/emulador"] = .directory + scenario.disk.addAppBundle(at: "/Emuladores/ps2/emulador/PCSX2.app", executable: "PCSX2") + #expect(scenario.discover().app.map { path($0.bundleURL) } == "/Emuladores/ps2/emulador/PCSX2.app") + } + + @Test("Modo portátil: lê a configuração ao lado do .app") + func portableMode() { + var scenario = PluginScenario(settings: nil) + scenario.disk["/Emuladores/ps2/portable.ini"] = .file() + scenario.disk["/Emuladores/ps2/inis"] = .directory + scenario.disk["/Emuladores/ps2/inis/PCSX2.ini"] = .file(Data("[Filenames]\nBIOS = SCPH-0001.bin\n[Folders]\nBios = ps2 bios\n".utf8)) + let discovery = scenario.discover() + #expect(discovery.settingsFile.map(path) == "/Emuladores/ps2/inis/PCSX2.ini") + #expect(discovery.bios == .configured("SCPH-0001.bin")) + } + + @Test("Sem configuração do PCSX2: BIOS desconhecida, mas a raiz ainda basta") + func withoutSettings() { + let discovery = PluginScenario(settings: nil).discover() + #expect(discovery.settingsFile == nil) + #expect(discovery.bios == .unknown) + #expect(discovery.romDirectories.map { path($0.url) } == ["/Emuladores/ps2/roms"]) + #expect(discovery.isReady) + } + + @Test("BIOS configurada mas ausente e pasta da lista de jogos que sumiu") + func brokenSettings() { + var scenario = PluginScenario(settings: """ + [Filenames] + BIOS = Sumiu.bin + [GameList] + RecursivePaths = /Disco Externo/PS2 + """) + scenario.disk["/Emuladores/ps2/ps2 bios/SCPH-0001.bin"] = nil + let discovery = scenario.discover() + #expect(discovery.bios == .fileMissing("Sumiu.bin")) + #expect(discovery.issues == [.settingsFolderMissing("/Disco Externo/PS2")]) + } + + @Test("Problemas: raiz inexistente, sem app, sem pastas de jogos") + func issues() { + #expect(PluginScenario().discover("/Nada").issues == [.rootNotFound]) + + var noApp = PluginScenario() + for key in noApp.disk.entries.keys where key.contains(".app") { noApp.disk.entries[key] = nil } + #expect(noApp.discover().issues == [.appNotFound]) + #expect(!noApp.discover().isReady) + + var noGames = PluginScenario(settings: nil) + noGames.disk["/Emuladores/ps2/roms"] = nil + noGames.disk["/Emuladores/ps2/roms/Jogo (USA).iso"] = nil + #expect(noGames.discover().issues == [.noROMDirectories]) + } + + @Test("ROMs soltas na raiz viram uma pasta não recursiva") + func loosROMsAtRoot() { + var scenario = PluginScenario(settings: nil) + scenario.disk["/Emuladores/ps2/Solto.chd"] = .file() + let directories = scenario.discover().romDirectories + #expect(directories.contains(ROMDirectory(url: directories.last!.url, origin: .rootFolder, isRecursive: false))) + #expect(directories.map { path($0.url) }.contains("/Emuladores/ps2")) + } +} + +struct INIFileTests { + @Test("Seções, chaves repetidas, comentários e espaços") + func parses() { + let ini = INIFile(""" + ; comentário + [GameList] + RecursivePaths = /a + recursivepaths=/b c + Paths = + # outro comentário + [Folders] + Bios = ../bios = com igual + """) + #expect(ini.values("RecursivePaths", in: "gamelist") == ["/a", "/b c"]) + #expect(ini.value("Paths", in: "GameList") == nil) + #expect(ini.value("Bios", in: "Folders") == "../bios = com igual") + #expect(ini.value("Bios", in: "GameList") == nil) + } +} diff --git a/Tests/RetroXCoreTests/PS2DiscReaderTests.swift b/Tests/RetroXCoreTests/PS2DiscReaderTests.swift new file mode 100644 index 0000000..a3d7a7f --- /dev/null +++ b/Tests/RetroXCoreTests/PS2DiscReaderTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import RetroXCore + +/// ISO 9660 mínima: descritor de volume, pasta raiz e um SYSTEM.CNF. Sem nenhum dado de jogo. +enum SyntheticISO { + static func make(systemConfig: String, fileName: String = "SYSTEM.CNF;1") -> Data { + let sector = 2048 + var bytes = [UInt8](repeating: 0, count: 22 * sector) + func put(_ value: UInt32, at offset: Int) { + for byte in 0..<4 { bytes[offset + byte] = UInt8((value >> (8 * UInt32(byte))) & 0xFF) } + } + func record(at offset: Int, sector extent: UInt32, size: UInt32, name: [UInt8]) -> Int { + let length = (33 + name.count + 1) & ~1 + bytes[offset] = UInt8(length) + put(extent, at: offset + 2) + put(size, at: offset + 10) + bytes[offset + 32] = UInt8(name.count) + bytes.replaceSubrange((offset + 33)..<(offset + 33 + name.count), with: name) + return length + } + + let descriptor = 16 * sector + bytes[descriptor] = 1 + bytes.replaceSubrange((descriptor + 1)...(descriptor + 5), with: Array("CD001".utf8)) + bytes[descriptor + 128] = 0x00 + bytes[descriptor + 129] = 0x08 // 2048, little-endian + _ = record(at: descriptor + 156, sector: 20, size: UInt32(sector), name: [0]) + + var offset = 20 * sector + offset += record(at: offset, sector: 20, size: UInt32(sector), name: [0]) + offset += record(at: offset, sector: 20, size: UInt32(sector), name: [1]) + let config = Array(systemConfig.utf8) + _ = record(at: offset, sector: 21, size: UInt32(config.count), name: Array(fileName.utf8)) + bytes.replaceSubrange((21 * sector)..<(21 * sector + config.count), with: config) + return Data(bytes) + } +} + +struct PS2DiscReaderTests { + @Test("Lê o serial do SYSTEM.CNF de uma ISO") + func readsSerial() throws { + let folder = try TemporaryFolder() + let iso = folder.url.appending(path: "Jogo (USA).iso") + try SyntheticISO.make(systemConfig: "BOOT2 = cdrom0:\\SLUS_214.90;1\r\nVER = 1.00\r\nVMODE = NTSC\r\n").write(to: iso) + #expect(PS2DiscReader.serial(of: iso) == "SLUS-21490") + } + + @Test("Sem SYSTEM.CNF, arquivo que não é ISO 9660, ou formato comprimido: sem serial") + func noSerial() throws { + let folder = try TemporaryFolder() + let other = folder.url.appending(path: "Outro.iso") + try SyntheticISO.make(systemConfig: "x", fileName: "README.TXT;1").write(to: other) + #expect(PS2DiscReader.serial(of: other) == nil) + #expect(PS2DiscReader.serial(of: try folder.file("Vazio.iso", bytes: 40_000)) == nil) + #expect(PS2DiscReader.serial(of: try folder.file("Jogo.chd", bytes: 10)) == nil) + } + + @Test("Formatos do BOOT2", arguments: [ + ("BOOT2 = cdrom0:\\SLUS_214.90;1", "SLUS-21490"), + ("boot2=cdrom0:\\sces_502.94;1", "SCES-50294"), + ("BOOT2 = cdrom0:\\SLES_556.76;1\nVER = 1.01", "SLES-55676"), + ("BOOT = cdrom:\\SLUS_000.01;1", nil), + ("BOOT2 = cdrom0:\\GAME.ELF;1", nil), + ] as [(String, String?)]) + func parsesBootLine(text: String, expected: String?) { + #expect(PS2DiscReader.serial(fromSystemConfig: text) == expected) + } + + @Test("Capa nomeada pelo serial vence a capa pelo nome") + func serialCoverWins() { + let covers = ["Test Drive Unlimited (USA).png", "SLUS-21490.jpg"].map { URL(filePath: "/c/\($0)") } + let match = CoverMatcher.match(romFilename: "Test Drive Unlimited (USA).iso", serial: "SLUS-21490", covers: covers) + #expect(match?.lastPathComponent == "SLUS-21490.jpg") + } +} + +struct PCSX2PluginVersionTests { + @Test("Com duas versões na pasta, usa a mais nova (comparação numérica)") + func prefersNewestVersion() { + var scenario = PluginScenario(settings: nil) + scenario.disk.addAppBundle(at: "/Emuladores/ps2/PCSX2-v2.10.0.app", executable: "PCSX2", version: "v2.10.0") + scenario.disk.addAppBundle(at: "/Emuladores/ps2/PCSX2-v2.9.147.app", executable: "PCSX2", version: "v2.9.147") + #expect(scenario.discover().app?.version == "v2.10.0") + } +} diff --git a/Tests/RetroXCoreTests/PreferencesTests.swift b/Tests/RetroXCoreTests/PreferencesTests.swift new file mode 100644 index 0000000..498b809 --- /dev/null +++ b/Tests/RetroXCoreTests/PreferencesTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import RetroXCore + +struct PreferencesTests { + @Test("Perfil antigo (sem preferências) abre com os padrões") + func olderProfileDecodes() throws { + let json = #"{"id":"6F9619FF-8B86-D011-B42D-00C04FC964FF","name":"Ana","theme":"nord","retroAchievementsUsername":"x"}"# + let profile = try JSONDecoder().decode(Profile.self, from: Data(json.utf8)) + #expect(profile.theme == .nord) + #expect(profile.preferences == ProfilePreferences()) + } + + @Test("Preferências parciais mantêm o resto no padrão") + func partialPreferences() throws { + let json = #"{"homeStyle":"carousel","textSize":"large","valorDesconhecido":true}"# + let preferences = try JSONDecoder().decode(ProfilePreferences.self, from: Data(json.utf8)) + #expect(preferences.homeStyle == .carousel) + #expect(preferences.textSize == .large) + #expect(preferences.fontStyle == .rounded) + #expect(preferences.ambientBackground) + } + + @Test("Ida e volta em JSON") + func roundTrip() throws { + var profile = Profile(name: "Bia") + profile.preferences.fontStyle = .serif + profile.preferences.animationLevel = .reduced + let decoded = try JSONDecoder().decode(Profile.self, from: JSONEncoder().encode(profile)) + #expect(decoded == profile) + } +} + +struct ControllerPresetTests { + @Test("Presets fixos têm glifo para todos os botões", arguments: [ControllerPreset.xbox, .playStation, .nintendo]) + func glyphs(preset: ControllerPreset) { + for control in ControllerControl.allCases { + #expect(preset.glyph(for: control) != nil, "\(preset) \(control)") + } + } + + @Test("Só o layout Nintendo confirma com o botão da direita") + func confirmButton() { + #expect(ControllerPreset.nintendo.confirmsWithEastButton) + #expect(!ControllerPreset.xbox.confirmsWithEastButton) + #expect(!ControllerPreset.playStation.confirmsWithEastButton) + #expect(ControllerPreset.nintendo.glyph(for: .east) == "a.circle") + #expect(ControllerPreset.playStation.glyph(for: .south) == "xmark.circle") + } + + @Test("Detecta a família pelo nome da categoria", arguments: [ + ("DualSense", ControllerPreset.playStation), ("DualShock 4", .playStation), + ("Xbox One", .xbox), ("Xbox Series X", .xbox), ("Switch Pro Controller", .nintendo), + ("Nintendo Switch Joy-Con (L/R)", .nintendo), ("8BitDo", .xbox), + ]) + func detects(category: String, expected: ControllerPreset) { + #expect(ControllerPreset.detect(productCategory: category) == expected) + } +} diff --git a/Tests/RetroXCoreTests/ProcessLaunchIntegrationTests.swift b/Tests/RetroXCoreTests/ProcessLaunchIntegrationTests.swift new file mode 100644 index 0000000..777952f --- /dev/null +++ b/Tests/RetroXCoreTests/ProcessLaunchIntegrationTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import RetroXCore + +/// Processo filho real e binário controlado, sem GUI, BIOS ou jogo. +@Suite(.timeLimit(.minutes(1)), .enabled(if: FakeEmulatorBundle.binaryURL != nil)) +struct ProcessLaunchIntegrationTests { + @Test("Preserva argv e observa término imediato, inclusive com erro", arguments: [Int32(0), 17]) + func immediateExit(status: Int32) async throws { + let fixture = try FakeEmulatorBundle() + defer { fixture.remove() } + let rom = try fixture.makeROM(named: LaunchIntegrationTests.file, inFolder: LaunchIntegrationTests.folder) + let arguments = ["--rx-headless-exit=\(status)", "--", rom.path(percentEncoded: false)] + let plan = LaunchPlan( + appURL: fixture.appURL, + executableURL: fixture.appURL.appending(path: "Contents/MacOS/FakeEmulator"), + bundleIdentifier: FakeEmulatorBundle.bundleIdentifier, + arguments: arguments, strategy: .process, activates: false, createsNewInstance: true + ) + let launched = try await FoundationProcessLauncher().launch(plan) + let exit = try #require(await launched.waitForExit()) + #expect(exit.status == status) + let received = try await fixture.waitForArguments(pid: launched.pid) + #expect(received.map { Array($0.utf8) } == arguments.map { Array($0.utf8) }) + #expect(await launched.waitForExit() == nil) + #expect(try Data(contentsOf: rom).isEmpty) + } + + @Test("Executável removido entre validação e execução retorna erro de launch") + func disappearedExecutable() async throws { + let fixture = try FakeEmulatorBundle() + defer { fixture.remove() } + let executable = fixture.appURL.appending(path: "Contents/MacOS/FakeEmulator") + try FileManager.default.removeItem(at: executable) + let plan = LaunchPlan( + appURL: fixture.appURL, executableURL: executable, + bundleIdentifier: FakeEmulatorBundle.bundleIdentifier, + arguments: [], strategy: .process, activates: false, createsNewInstance: true + ) + do { + _ = try await FoundationProcessLauncher().launch(plan) + Issue.record("O executável ausente não deveria iniciar") + } catch { + guard case .launchFailed = error else { Issue.record("Erro inesperado: \(error)"); return } + } + } +} diff --git a/Tests/RetroXCoreTests/ProfileAndMetadataTests.swift b/Tests/RetroXCoreTests/ProfileAndMetadataTests.swift new file mode 100644 index 0000000..92e9fa5 --- /dev/null +++ b/Tests/RetroXCoreTests/ProfileAndMetadataTests.swift @@ -0,0 +1,253 @@ +import CoreGraphics +import Foundation +import ImageIO +import Testing +@testable import RetroXCore + +struct ProfileTests { + @Test("Iniciais do avatar sem foto", arguments: [ + ("Ana Souza", "AS"), ("Jogador 1", "J1"), ("léo", "L"), (" ", "?"), ("Maria da Silva", "MD"), + ]) + func initials(name: String, expected: String) { + #expect(Profile(name: name).initials == expected) + } + + @Test("Perfil de versão anterior (sem tema e aparência) abre com os padrões") + func decodesOlderFiles() throws { + let json = #"{"id":"6F9619FF-8B86-D011-B42D-00C04FC964FF","name":"Ana"}"# + let profile = try JSONDecoder().decode(Profile.self, from: Data(json.utf8)) + #expect(profile.theme == .retrox) + #expect(profile.appearance == .system) + #expect(profile.accent == .coral) + } + + @Test("Histórico antigo passa para o primeiro perfil e o arquivo antigo é guardado") + func migratesLegacyHistory() throws { + let folder = try TemporaryFolder() + let storage = ProfileStorage(root: folder.url) + let legacy = ["/jogos/a.iso": GameHistory(isFavorite: true, playCount: 3)] + try JSONFileStore<[String: GameHistory]>(url: folder.url.appending(path: "history.json")).save(legacy) + let profile = Profile(name: "Jogador 1") + + try storage.migrateLegacyHistory(into: profile.id) + #expect(storage.history(for: profile.id).load() == legacy) + #expect(FileManager.default.fileExists(atPath: folder.url.appending(path: "history.legacy.json").path(percentEncoded: false))) + #expect(!FileManager.default.fileExists(atPath: folder.url.appending(path: "history.json").path(percentEncoded: false))) + + // Rodar de novo não apaga nem sobrescreve nada. + try storage.migrateLegacyHistory(into: profile.id) + #expect(storage.history(for: profile.id).load() == legacy) + } +} + +struct ImageImporterTests { + private func writePNG(width: Int, height: Int, to url: URL) throws { + let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + context.setFillColor(red: 0.8, green: 0.3, blue: 0.2, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil)! + CGImageDestinationAddImage(destination, context.makeImage()!, nil) + #expect(CGImageDestinationFinalize(destination)) + } + + @Test("Copia reduzido, sem tocar no original") + func importsDownsampled() throws { + let folder = try TemporaryFolder() + let source = folder.url.appending(path: "foto.png") + try writePNG(width: 2000, height: 1000, to: source) + let before = try Data(contentsOf: source) + + let destination = folder.url.appending(path: "Avatars/eu.png") + try ImageImporter.importPNG(from: source, to: destination, maxPixelSize: 512) + let image = CGImageSourceCreateImageAtIndex(CGImageSourceCreateWithURL(destination as CFURL, nil)!, 0, nil)! + #expect(max(image.width, image.height) == 512) + #expect(try Data(contentsOf: source) == before) + + // Importar de novo substitui a cópia anterior. + try ImageImporter.importPNG(from: source, to: destination, maxPixelSize: 256) + let replaced = CGImageSourceCreateImageAtIndex(CGImageSourceCreateWithURL(destination as CFURL, nil)!, 0, nil)! + #expect(max(replaced.width, replaced.height) == 256) + } + + @Test("Arquivo que não é imagem") + func rejectsNonImages() throws { + let folder = try TemporaryFolder() + let source = try folder.file("nota.txt", bytes: 10) + #expect(throws: ImageImporterError.unreadableImage) { + try ImageImporter.importPNG(from: source, to: folder.url.appending(path: "x.png"), maxPixelSize: 100) + } + } + + @Test("Nome da capa é estável e só tem caracteres seguros") + func coverFileName() { + let name = ArtworkStorage.coverFileName(for: "/Volumes/Jogos Ação/Jogo (USA).iso") + #expect(name == ArtworkStorage.coverFileName(for: "/Volumes/Jogos Ação/Jogo (USA).iso")) + #expect(name.range(of: "^[0-9a-f]{24}\\.png$", options: .regularExpression) != nil) + } +} + +struct GameIndexTests { + let yaml = """ + # comentário + SCUS-97436: + name: "Gran Turismo 4 [Online Public Beta]" + region: "NTSC-U" + clampModes: *gran-turismo-4-clamp-modes + SLUS-21490: + name: "Test Drive Unlimited" + region: "NTSC-U" + compat: 5 + gameFixes: + - SoftwareRendererFMVHack + patches: + default: + name: "não é o nome do jogo" + SLUS-99999: + name: "Outro jogo" + compat: 3 + """ + + @Test("Lê nome, região e compatibilidade só dos seriais pedidos") + func parses() { + let entries = PCSX2GameIndex.parse(yaml, serials: ["SLUS-21490", "SCUS-97436"]) + #expect(entries.count == 2) + #expect(entries["SLUS-21490"] == GameIndexEntry(name: "Test Drive Unlimited", region: "NTSC-U", compatibility: 5)) + #expect(entries["SCUS-97436"]?.name == "Gran Turismo 4 [Online Public Beta]") + #expect(entries["SCUS-97436"]?.compatibility == nil) + } +} + +struct PluginEmulatorSettingsTests { + @Test("Controle vem do PCSX2.ini; nada da seção de conquistas (nem o token) é lido") + func controllerOnly() { + let scenario = PluginScenario(settings: """ + [Achievements] + Enabled = true + ChallengeMode = true + Username = jogadora + Token = SEGREDO-NAO-PODE-VAZAR + [Pad1] + Type = DualShock2 + Cross = SDL-0/FaceSouth + Circle = SDL-0/FaceEast + Start = SDL-0/Start + """) + let discovery = scenario.discover() + #expect(discovery.controller?.type == "DualShock2") + #expect(discovery.controller?.device == "SDL-0") + #expect(discovery.controller?.bindings.first == ControllerSummary.Binding(button: "Cross", input: "SDL-0/FaceSouth")) + #expect(!String(describing: discovery).contains("SEGREDO")) + #expect(!String(describing: discovery).contains("jogadora")) + } + + @Test("Sem [Pad1], sem resumo de controle") + func noController() { + #expect(PluginScenario(settings: "[UI]\nStartFullscreen = true\n").discover().controller == nil) + } + + @Test("Aponta o GameIndex.yaml de dentro do app, quando existe") + func gameIndexFile() { + var scenario = PluginScenario() + scenario.disk["/Emuladores/ps2/PCSX2-v2.9.144.app/Contents/Resources"] = .directory + scenario.disk["/Emuladores/ps2/PCSX2-v2.9.144.app/Contents/Resources/GameIndex.yaml"] = .file() + #expect(scenario.discover().gameIndexFile?.lastPathComponent == "GameIndex.yaml") + #expect(PluginScenario().discover().gameIndexFile == nil) + } +} + +/// Responde por trecho de URL; o que não estiver mapeado falha como servidor fora do ar. +struct FakeHTTP: HTTPFetching { + var responses: [String: String] + + func data(from url: URL) async throws -> Data { + let address = url.absoluteString.removingPercentEncoding ?? url.absoluteString + guard let match = responses.first(where: { address.contains($0.key) }) else { throw MetadataError.http(503) } + return Data(match.value.utf8) + } +} + +struct WikipediaMetadataProviderTests { + static let ptSearch = #""" + {"query":{"search":[{"title":"Test Drive Unlimited","snippet":"Test Drive Unlimited é um jogo eletrônico de corrida & mundo aberto"}]}} + """# + static let ptSummary = #""" + {"title":"Test Drive Unlimited","extract":"Test Drive Unlimited é um jogo eletrônico de corrida de 2006.","description":"videojogo de 2006","wikibase_item":"Q853941","content_urls":{"desktop":{"page":"https://pt.wikipedia.org/wiki/Test_Drive_Unlimited"}}} + """# + static let claims = #""" + {"entities":{"Q853941":{"claims":{ + "P577":[{"mainsnak":{"datavalue":{"value":{"time":"+2007-03-16T00:00:00Z"}}}},{"mainsnak":{"datavalue":{"value":{"time":"+2006-09-05T00:00:00Z"}}}}], + "P178":[{"mainsnak":{"datavalue":{"value":{"id":"Q1"}}}}], + "P123":[{"mainsnak":{"datavalue":{"value":{"id":"Q2"}}}}], + "P136":[{"mainsnak":{"datavalue":{"value":{"id":"Q3"}}}},{"mainsnak":{"snaktype":"novalue"}}] + }}}} + """# + static let labels = #""" + {"entities":{"Q1":{"labels":{"en":{"value":"Eden Games"}}},"Q2":{"labels":{"pt":{"value":"Atari"}}},"Q3":{"labels":{"pt":{"value":"jogo eletrônico de corrida"}}}}} + """# + + @Test("Busca em português e limpa o HTML do trecho") + func searchesPortuguese() async throws { + let provider = WikipediaMetadataProvider(http: FakeHTTP(responses: ["pt.wikipedia.org/w/api.php": Self.ptSearch])) + let results = try await provider.search("Test Drive Unlimited") + #expect(results == [MetadataCandidate( + title: "Test Drive Unlimited", snippet: "Test Drive Unlimited é um jogo eletrônico de corrida & mundo aberto", language: "pt" + )]) + } + + @Test("Sem resultado em português, tenta em inglês") + func fallsBackToEnglish() async throws { + let provider = WikipediaMetadataProvider(http: FakeHTTP(responses: [ + "pt.wikipedia.org/w/api.php": #"{"query":{"search":[]}}"#, + "en.wikipedia.org/w/api.php": #"{"query":{"search":[{"title":"Raro","snippet":"a video game"}]}}"#, + ])) + #expect(try await provider.search("Raro").map(\.language) == ["en"]) + } + + @Test("Resumo da Wikipedia completado pelo Wikidata") + func metadataWithWikidata() async throws { + let provider = WikipediaMetadataProvider(http: FakeHTTP(responses: [ + "rest_v1/page/summary/Test_Drive_Unlimited": Self.ptSummary, + "props=claims": Self.claims, + "props=labels": Self.labels, + ])) + let candidate = MetadataCandidate(title: "Test Drive Unlimited", snippet: "", language: "pt") + let metadata = try await provider.metadata(for: candidate) + #expect(metadata.summary == "Test Drive Unlimited é um jogo eletrônico de corrida de 2006.") + #expect(metadata.releaseYear == 2006) + #expect(metadata.developers == ["Eden Games"]) + #expect(metadata.publishers == ["Atari"]) + #expect(metadata.genres == ["jogo eletrônico de corrida"]) + #expect(metadata.sourceName == "Wikipedia (pt)") + #expect(metadata.sourceURL?.absoluteString == "https://pt.wikipedia.org/wiki/Test_Drive_Unlimited") + } + + @Test("Wikidata fora do ar: fica só o resumo, sem erro") + func wikidataDown() async throws { + let provider = WikipediaMetadataProvider(http: FakeHTTP(responses: ["rest_v1/page/summary/": Self.ptSummary])) + let metadata = try await provider.metadata(for: MetadataCandidate(title: "Test Drive Unlimited", snippet: "", language: "pt")) + #expect(metadata.releaseYear == nil) + #expect(metadata.developers.isEmpty) + #expect(!metadata.summary.isEmpty) + } + + @Test("Wikipedia fora do ar: o erro chega a quem chamou") + func wikipediaDown() async { + let provider = WikipediaMetadataProvider(http: FakeHTTP(responses: [:])) + await #expect(throws: MetadataError.http(503)) { try await provider.search("x") } + } +} + +struct SearchTitleTests { + @Test("Título para busca", arguments: [ + ("GT 4 Spec II v1.08", "GT 4 Spec II"), + ("Test Drive Unlimited (USA)", "Test Drive Unlimited"), + ("PES 2014 - Pro Evolution Soccer (Europe) (En,Nl,Sv,Ru,Tr)", "PES 2014 - Pro Evolution Soccer"), + ("Jogo [!] [T-Por]", "Jogo"), + ]) + func cleans(title: String, expected: String) { + #expect(TitleParser.searchTitle(title) == expected) + } +} diff --git a/Tests/RetroXCoreTests/Support/FakeEmulatorBundle.swift b/Tests/RetroXCoreTests/Support/FakeEmulatorBundle.swift new file mode 100644 index 0000000..a21254e --- /dev/null +++ b/Tests/RetroXCoreTests/Support/FakeEmulatorBundle.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Monta `FakeEmulator.app` num diretório temporário a partir do binário `rx-fake-emulator`. +/// O caminho do binário vem de `RX_FAKE_EMULATOR_BIN` (definido por `scripts/integration-launch.sh`). +struct FakeEmulatorBundle { + static let bundleIdentifier = "local.retrox.fake-emulator" + + static var binaryURL: URL? { + ProcessInfo.processInfo.environment["RX_FAKE_EMULATOR_BIN"].map { URL(filePath: $0) } + } + + let root: URL + let appURL: URL + + init() throws { + guard let binaryURL = Self.binaryURL else { throw CocoaError(.fileNoSuchFile) } + root = FileManager.default.temporaryDirectory + .appending(path: "RetroXIntegration-\(UUID().uuidString)", directoryHint: .isDirectory) + appURL = root.appending(path: "FakeEmulator.app", directoryHint: .isDirectory) + let macOSURL = appURL.appending(path: "Contents/MacOS", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: macOSURL, withIntermediateDirectories: true) + try FileManager.default.copyItem(at: binaryURL, to: macOSURL.appending(path: "FakeEmulator")) + let plist: [String: Any] = [ + "CFBundleExecutable": "FakeEmulator", + "CFBundleIdentifier": Self.bundleIdentifier, + "CFBundleName": "FakeEmulator", + "CFBundlePackageType": "APPL", + "LSUIElement": true, + ] + try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + .write(to: appURL.appending(path: "Contents/Info.plist")) + } + + /// Cria um arquivo vazio com a extensão certa: o emulador falso não lê o conteúdo. + func makeROM(named name: String, inFolder folder: String) throws -> URL { + let folderURL = root.appending(path: folder, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + let romURL = folderURL.appending(path: name, directoryHint: .notDirectory) + guard FileManager.default.createFile(atPath: romURL.path(percentEncoded: false), contents: Data()) else { + throw CocoaError(.fileWriteUnknown) + } + return romURL + } + + func argumentFiles() throws -> [URL] { + try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) + .filter { $0.lastPathComponent.hasPrefix("argv-") } + } + + func waitForArguments(pid: pid_t, timeout: Duration = .seconds(15)) async throws -> [String] { + let fileURL = root.appending(path: "argv-\(pid).json") + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if let data = try? Data(contentsOf: fileURL) { + return try JSONDecoder().decode([String].self, from: data) + } + try await Task.sleep(for: .milliseconds(100)) + } + throw CocoaError(.fileReadNoSuchFile) + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +} diff --git a/Tests/RetroXCoreTests/Support/FakeFileSystem.swift b/Tests/RetroXCoreTests/Support/FakeFileSystem.swift new file mode 100644 index 0000000..419be8e --- /dev/null +++ b/Tests/RetroXCoreTests/Support/FakeFileSystem.swift @@ -0,0 +1,99 @@ +import Foundation +@testable import RetroXCore + +/// Disco em memória: caminho → entrada. Caminhos fictícios, nada da máquina real. +struct FakeFileSystem: FileSystemProbing { + enum Entry { + case directory + case file(Data = Data(), readable: Bool = true, executable: Bool = false) + } + + var entries: [String: Entry] = [:] + + subscript(path: String) -> Entry? { + get { entries[Self.normalize(path)] } + set { entries[Self.normalize(path)] = newValue } + } + + func kind(of url: URL) -> FileItemKind { + switch self[url.path(percentEncoded: false)] { + case nil: .missing + case .directory: .directory + case .file: .regularFile + } + } + + func isReadable(_ url: URL) -> Bool { + if case .file(_, let readable, _) = self[url.path(percentEncoded: false)] { return readable } + return false + } + + func isExecutable(_ url: URL) -> Bool { + if case .file(_, _, let executable) = self[url.path(percentEncoded: false)] { return executable } + return false + } + + func readPrefix(of url: URL, maxLength: Int) throws -> Data { + guard case .file(let data, true, _) = self[url.path(percentEncoded: false)] else { + throw CocoaError(.fileReadNoSuchFile) + } + return data.prefix(maxLength) + } + + func contentsOfDirectory(at url: URL) -> [URL] { + let parent = Self.normalize(url.path(percentEncoded: false)) + return entries.keys + .filter { ($0 as NSString).deletingLastPathComponent == parent && !($0 as NSString).lastPathComponent.hasPrefix(".") } + .sorted() + .map { URL(filePath: $0) } + } + + /// Cria um `.app` mínimo: Info.plist e executável com o cabeçalho Mach-O dado. + mutating func addAppBundle( + at path: String, + executable: String = "Emu", + bundleIdentifier: String? = "net.pcsx2.pcsx2", + version: String? = "v2.9.144", + header: Data = MachOSamples.thinX86_64, + executableIsExecutable: Bool = true + ) { + var plist: [String: Any] = ["CFBundleExecutable": executable] + plist["CFBundleIdentifier"] = bundleIdentifier + plist["CFBundleShortVersionString"] = version + let plistData = try! PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + self[path] = .directory + self["\(path)/Contents"] = .directory + self["\(path)/Contents/Info.plist"] = .file(plistData) + self["\(path)/Contents/MacOS"] = .directory + self["\(path)/Contents/MacOS/\(executable)"] = .file(header, executable: executableIsExecutable) + } + + private static func normalize(_ path: String) -> String { + path.count > 1 && path.hasSuffix("/") ? String(path.dropLast()) : path + } +} + +enum MachOSamples { + /// MH_MAGIC_64 em little-endian + cputype. + static let thinX86_64 = Data([0xCF, 0xFA, 0xED, 0xFE, 0x07, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00]) + static let thinARM64 = Data([0xCF, 0xFA, 0xED, 0xFE, 0x0C, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]) + + /// FAT_MAGIC com duas entradas de 20 bytes: x86_64 e arm64. + static let universal: Data = { + var bytes: [UInt8] = [0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x02] + bytes += [0x01, 0x00, 0x00, 0x07] + [UInt8](repeating: 0, count: 16) + bytes += [0x01, 0x00, 0x00, 0x0C] + [UInt8](repeating: 0, count: 16) + return Data(bytes) + }() + + /// FAT_MAGIC_64 com uma entrada de 32 bytes: arm64. + static let universal64: Data = { + var bytes: [UInt8] = [0xCA, 0xFE, 0xBA, 0xBF, 0x00, 0x00, 0x00, 0x01] + bytes += [0x01, 0x00, 0x00, 0x0C] + [UInt8](repeating: 0, count: 28) + return Data(bytes) + }() + + /// Cabeçalho de um .class Java: CAFEBABE seguido de versão, não de contagem de arquiteturas. + static let javaClass = Data([0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x41]) + static let shellScript = Data("#!/bin/sh\n".utf8) +} diff --git a/Tests/RetroXCoreTests/Support/LaunchFakes.swift b/Tests/RetroXCoreTests/Support/LaunchFakes.swift new file mode 100644 index 0000000..073f6c4 --- /dev/null +++ b/Tests/RetroXCoreTests/Support/LaunchFakes.swift @@ -0,0 +1,67 @@ +import Foundation +@testable import RetroXCore + +struct FakeRunningApplications: RunningApplicationsProviding { + var running: Set = [] + + func isRunning(bundleIdentifier: String) -> Bool { + running.contains(bundleIdentifier) + } + + func activate(bundleIdentifier: String) -> Bool { + running.contains(bundleIdentifier) + } +} + +/// Não executa nada: guarda os planos recebidos e devolve um processo que já terminou. +actor RecordingLauncher: ProcessLaunching { + private(set) var plans: [LaunchPlan] = [] + let exit: ProcessExit + + init(exit: ProcessExit = ProcessExit(status: 0, endedAt: Date(timeIntervalSince1970: 60))) { + self.exit = exit + } + + func launch(_ plan: LaunchPlan) async throws(LaunchError) -> LaunchedProcess { + plans.append(plan) + let (exits, continuation) = AsyncStream.makeStream(of: ProcessExit.self) + continuation.yield(exit) + continuation.finish() + return LaunchedProcess(pid: 4242, launchedAt: Date(timeIntervalSince1970: 0), plan: plan, exits: exits) + } +} + +/// Cenário padrão: PCSX2 x86_64 em Apple Silicon com Rosetta, e uma ROM legível. +struct LaunchScenario { + var disk = FakeFileSystem() + var host = HostEnvironment(architecture: .arm64, isRosettaInstalled: true) + var running = FakeRunningApplications() + var romPath = "/Volumes/Jogos Ação/Test Drive Unlimited (USA).iso" + var appPath = "/Apps/PCSX2.app" + var profile: EmulatorProfile + + init() { + profile = EmulatorProfile( + name: "PCSX2", + adapterID: .pcsx2, + appURL: URL(filePath: appPath), + options: PCSX2Adapter().defaultOptions + ) + disk.addAppBundle(at: appPath, executable: "PCSX2") + disk[romPath] = .file() + } + + var request: LaunchRequest { + LaunchRequest(romURL: URL(filePath: romPath), systemID: .ps2, profile: profile) + } + + func launcher(recording: RecordingLauncher = RecordingLauncher()) -> GameLauncher { + GameLauncher( + registry: .standard, + fileSystem: disk, + host: host, + runningApplications: running, + launchers: [.workspace: recording, .process: recording] + ) + } +} diff --git a/Tests/RetroXCoreTests/ThemeTests.swift b/Tests/RetroXCoreTests/ThemeTests.swift new file mode 100644 index 0000000..7f365c5 --- /dev/null +++ b/Tests/RetroXCoreTests/ThemeTests.swift @@ -0,0 +1,59 @@ +import Testing +@testable import RetroXCore + +private let allPalettes: [(String, ThemePalette)] = ThemeID.allCases.flatMap { theme in + [false, true].flatMap { dark in + let accents: [Palette.Accent] = theme == .retrox ? Palette.Accent.allCases : [.coral] + return accents.map { accent in + ("\(theme.title) \(theme.variantName(dark: dark)) \(theme == .retrox ? accent.rawValue : "")", theme.palette(dark: dark, retroXAccent: accent)) + } + } +} + +struct ThemeTests { + @Test("Há pelo menos 10 temas, cada um com claro e escuro") + func count() { + #expect(ThemeID.allCases.count >= 10) + } + + @Test("Contraste AA em todos os temas e modos", arguments: allPalettes) + func contrast(name: String, palette: ThemePalette) { + for background in [palette.background, palette.surface, palette.raised] { + #expect(HexColor.contrast(palette.textPrimary, background) >= 4.5, "\(name): texto") + #expect(HexColor.contrast(palette.textSecondary, background) >= 4.5, "\(name): texto secundário") + } + #expect(HexColor.contrast(palette.onAccent, palette.accent) >= 4.5, "\(name): texto sobre o acento") + #expect(HexColor.contrast(palette.accent, palette.background) >= 3, "\(name): anel sobre o fundo") + #expect(HexColor.contrast(palette.accent, palette.surface) >= 3, "\(name): anel sobre a superfície") + } + + @Test("Texto principal se destaca mais que o secundário", arguments: allPalettes) + func hierarchy(name: String, palette: ThemePalette) { + #expect( + HexColor.contrast(palette.textPrimary, palette.background) > HexColor.contrast(palette.textSecondary, palette.background), + "\(name)" + ) + } + + @Test("O X da marca é legível em todo o gradiente, em todos os temas", arguments: allPalettes) + func brandX(name: String, palette: ThemePalette) { + let dark = palette.background.luminance < 0.2 + for stop in Palette.brandXStops(palette, dark: dark) { + #expect(HexColor.contrast(stop, palette.background) >= 3, "\(name): X sobre o fundo") + } + } + + @Test("Gradiente em OKLCH começa e termina nas cores pedidas") + func oklchEndpoints() { + let stops = HexColor.oklchStops(from: HexColor("FEB5B8"), to: HexColor("7BADFD")) + for (stop, expected) in [(stops.first!, HexColor("FEB5B8")), (stops.last!, HexColor("7BADFD"))] { + #expect(abs(stop.red - expected.red) < 1.0 / 255 && abs(stop.green - expected.green) < 1.0 / 255 + && abs(stop.blue - expected.blue) < 1.0 / 255) + } + } + + @Test("Capas geradas legíveis para todos os sistemas do catálogo", arguments: SystemCatalog.all.map(\.id)) + func coverTones(system: GameSystemID) { + #expect(HexColor.contrast(Palette.onCover.light, Palette.coverTone(for: system).light) >= 4.5) + } +} diff --git a/Tests/RetroXCoreTests/TitleParserTests.swift b/Tests/RetroXCoreTests/TitleParserTests.swift new file mode 100644 index 0000000..2033184 --- /dev/null +++ b/Tests/RetroXCoreTests/TitleParserTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import RetroXCore + +struct TitleParserTests { + @Test("Remove a extensão e mantém a região") + func removesExtensionKeepsRegion() { + #expect(TitleParser.displayTitle(fromFilename: "Test Drive Unlimited (USA).iso") == "Test Drive Unlimited (USA)") + } + + @Test("Troca _ por espaço e colapsa espaços") + func normalizesSpacing() { + #expect(TitleParser.displayTitle(fromFilename: "Gran_Turismo 4 .chd") == "Gran Turismo 4") + } + + @Test("Converte nomes em NFD para NFC") + func convertsToNFC() { + let nfd = "Ac\u{0327}a\u{0303}o.iso" + let title = TitleParser.displayTitle(fromFilename: nfd) + #expect(Array(title.utf8) == Array("Ação".precomposedStringWithCanonicalMapping.utf8)) + } + + @Test("Nome sem base mantém o original") + func keepsFilenameWhenBaseIsEmpty() { + #expect(TitleParser.displayTitle(fromFilename: "___.iso") == "___.iso") + } + + @Test("Chave de busca ignora caixa, acentos e forma Unicode") + func searchKeyFolds() { + #expect(TitleParser.searchKey("AÇÃO") == TitleParser.searchKey("acao")) + #expect(TitleParser.searchKey("Ac\u{0327}a\u{0303}o") == TitleParser.searchKey("ação")) + } +} diff --git a/docs/DECISOES.md b/docs/DECISOES.md new file mode 100644 index 0000000..318ce94 --- /dev/null +++ b/docs/DECISOES.md @@ -0,0 +1,47 @@ +# Registro de decisões e suposições + +Decisões do usuário e suposições de trabalho que não cabem num ADR. Decisões de arquitetura ficam em `docs/adr/`. + +## Decisões do usuário (2026-10-27) + +| ID | Decisão | +|---|---| +| D1 | O usuário instala o Xcode antes da Fase 2. O SwiftData (ADR-0005) fica mantido. | +| D2 | Git autorizado: repositório local, commits pequenos por tarefa, sem remote e sem push. | +| D3 | O produto passa a se chamar **RetroX** (módulos `RetroXCore`, `RetroXApp`…). "ConsoleShelf" fica só como histórico em `docs/origem/`. | + +## Decisões do usuário (2026-10-28) + +| ID | Decisão | +|---|---| +| D4 | Emuladores são **plugins** que o usuário ativa; o primeiro é o plugin nativo do PCSX2 (ADR-0013). | +| D5 | A "pasta raiz" de um emulador é a pasta que guarda o app dele junto com ROMs, capas e BIOS (ex.: `~/Emulators/ps2`). | +| D6 | Direção visual **"Portátil quente"** (docs/design/brand-spec.md), com a usabilidade de launcher de portátil inspirada no iiSU e identidade própria. | +| D7 | A skill `swiftui-design-skill` faz parte do projeto (`.claude/skills/`); os passos dela de baixar assets de marca não se aplicam. | +| D8 | Perfis de jogador com tela de seleção ao abrir (estilo console). Favoritos, recentes e tema são por perfil; capa própria e informações dos jogos são compartilhadas. | +| D9 | 11 temas de cor (RetroX + 10 esquemas conhecidos), cada um claro e escuro, escolhidos por perfil. | +| D10 | Informações de jogos: banco do PCSX2 offline e Wikipedia/Wikidata sob demanda, com escolha do resultado (ADR-0014). Capas não são baixadas da internet. | +| D11 | ~~RetroAchievements e controle do PCSX2 em leitura.~~ Revisto em D12. | +| D12 | RetroAchievements fica fora do RetroX (pedido do usuário em 2026-10-28): nada é lido nem mostrado. O controle do PCSX2 continua só em leitura; gravar perfil de entrada depende de consentimento e teste com controle (spike 0002). | +| D13 | Ajustes dentro da janela, navegáveis por controle, com presets Xbox, PlayStation (DualSense) e Switch. Fonte, tamanho do texto, visualização da tela inicial (Prateleira, Carrossel, Grade), fundo ambiente e animações são preferências do perfil. | +| D14 | Abertura animada ao iniciar e transições suaves entre telas, inspiradas na fluidez do iiSU, com identidade própria. | +| D15 | Marca do RetroX: a personagem **Rayo** (tile do ícone) e o nome com o "X" na cor de destaque, alinhados pela altura das maiúsculas. Substitui o R geométrico (conceito C). Kit e regras em `docs/design/logo/README.md`, gerados por `scripts/brand-kit.swift`. | + +## Suposições de trabalho + +| ID | Suposição | Revisar em | +|---|---|---| +| S1 | Bundle ID provisório `local.retrox.RetroX`, trocado antes de qualquer assinatura Developer ID. | Fase 7 | +| S2 | A versão de PCSX2 validada é a 2.9.144 (macOS, x86_64 via Rosetta). Outras versões podem mudar a CLI. | A cada nova versão do PCSX2 | +| S3 | Por padrão o RetroX respeita a configuração do emulador (tela cheia, confirmação ao sair) e não grava nos arquivos de configuração dele. | — | +| S4 | Dolphin não está instalado na máquina de desenvolvimento; o adapter é testado com fakes até que esteja. | Fase 4 | +| S5 | Deployment target macOS 14 (do PRD), com a troca para macOS 15 em aberto. | Gate da Fase 1 | +| S6 | Até a Fase 2, o ID de um jogo é o caminho do arquivo; favoritos e histórico ficam em JSON. | Fase 2 | +| S7 | A CLI do PCSX2 2.9.147 é igual à da 2.9.144 validada no spike (conferido no texto de ajuda do binário). | A cada versão | + +## Pendências que dependem do usuário + +- Instalar o Xcode e rodar `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer` (Fase 1). +- Instalar o Dolphin para validar a Fase 4. +- Autorizar (ou não) o RetroX a criar um perfil de entrada próprio na pasta do PCSX2 e testar com um controle físico (spike 0002). +- Escolher o bundle ID definitivo e ter conta Apple Developer para assinatura e notarização (Fase 7). diff --git a/docs/PRD.md b/docs/PRD.md index 6675d29..ee7b50d 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,10 +1,12 @@ -# PRD — ConsoleShelf (Product Requirements Document) +# PRD — RetroX (Product Requirements Document) -**Versão:** 0.1 | **Status:** proposta para validação | **Plataforma:** macOS 14+ | **Idioma da interface inicial:** português do Brasil (arquitetura pronta para localização) +**Versão:** 0.2 | **Status:** revisado após o plano técnico (ver §12) | **Plataforma:** macOS 14+ | **Idioma da interface inicial:** português do Brasil (arquitetura pronta para localização) + +> Nome anterior (provisório): ConsoleShelf. A especificação original está em `docs/origem/`. ## 1. Visão e problema -Pessoas que usam múltiplos emuladores no macOS guardam ROMs em diferentes pastas e precisam abrir manualmente cada emulador e escolher o jogo. A experiência é fragmentada e a organização depende de ferramentas diversas. **ConsoleShelf** resolve isso com uma biblioteca e um launcher unificados, de estética inspirada em interfaces de consoles portáteis, sem implementar emulação. +Pessoas que usam múltiplos emuladores no macOS guardam ROMs em diferentes pastas e precisam abrir manualmente cada emulador e escolher o jogo. A experiência é fragmentada e a organização depende de ferramentas diversas. **RetroX** resolve isso com uma biblioteca e um launcher unificados, de estética inspirada em interfaces de consoles portáteis, sem implementar emulação. ### Proposta de valor @@ -133,3 +135,23 @@ Pessoas que usam múltiplos emuladores no macOS guardam ROMs em diferentes pasta ## 11. Futuro, após MVP Extensões para outros adapters e formatos, RetroAchievements, provedores opcionais de metadados com limites de API, layouts alternativos (incluindo inspiração dual-screen), temas próprios, assets animados, importação gerenciada explicitamente opt-in e telemetria apenas se houver consentimento. Validar demanda antes de implementar cada item. + +## 12. Revisão 0.2 (2026-10-27) + +Resultado da revisão feita no plano técnico aprovado. Prevalece sobre as seções anteriores em caso de conflito. + +| Ref. | Ajuste | +|---|---| +| RF-03 | A detecção **exata** de arquivo movido (mesmo volume + mesmo `fileIdentifier`) passa a ser Must, porque sem ela o "ID persistente" se perde num simples mover de pasta. A UI de revisão de movidos/duplicatas continua em RF-14 (Should). | +| RF-02 | "Detecção assistida" significa, em ordem: (1) dica de sistema da pasta-fonte; (2) leitura de cabeçalho de até 64 KB, sem hash; (3) extensão inequívoca. Arquivo ambíguo fica em `needsReview` e nunca é atribuído em silêncio. Ver ADR-0012. | +| RF-05 | "Compatibilidade" = bundle válido, executável presente e executável, bundle ID esperado (aviso se diferente), arquitetura executável no host (x86_64 em Apple Silicon exige Rosetta) e versão registrada. Sem bloqueio por versão no MVP. | +| RF-06 | A evidência de "testado no macOS" fica em `docs/spikes/`. O critério do Dolphin fica pendente até haver Dolphin instalado na máquina de teste. | +| Jornadas 2 e 5 | Dependem de requisitos Should (RF-11, RF-12). No MVP Must, a home é o Library Mode; recentes e favoritos aparecem quando existirem. | +| RF-08 | Hipótese a medir, não promessa: filtro em menos de 100 ms com 5.000 itens. | +| §7 Dados | `GameSystem` vira catálogo estático em código; preferências do usuário por sistema ficam em `SystemPreference`. `GameEmulatorOverride` vira relacionamento opcional no jogo (1:1). | +| Formatos | PS2: `iso`, `chd`, `cso`, `zso` (declarados pelo bundle do PCSX2 2.9.144). `mdf`, `bin/cue` e `elf` depois, com teste. GameCube/Wii: `iso`, `gcm`, `rvz`, `wbfs`, `ciso`, `gcz`, `wia`, pendente de verificação com o Dolphin. ZIP/7z: nunca. | +| Jornada 2 | "Retornar à biblioteca" = o emulador é aberto em modo batch (fecha ao parar o jogo) e o RetroX volta à frente quando o processo termina. | +| RNF-09 | Além de Xcode/`xcodebuild`, o caminho reproduzível sem Xcode é `swift build` / `swift test` (SwiftPM). | +| Plataforma | O deployment target macOS 14 compila, mas não é testável na máquina de desenvolvimento atual (macOS 27). A escolha entre 14 e 15 será feita no gate da Fase 1 (ver ADR-0005). | +| Emuladores (2026-10-28) | Emuladores viram plugins ativados pelo usuário, cada um com uma pasta raiz (ADR-0013). O onboarding e a jornada 1 passam a ser "ativar o emulador → escolher a pasta dele → ver os jogos"; o RF-01 (escolher pastas de ROMs) é atendido pela descoberta do plugin e pelas pastas que o próprio emulador já conhece. | +| Home (2026-10-28) | A home é o layout de portátil (palco + prateleira) desde já, com teclado, mouse e controle; o Console Mode em tela cheia (RF-11) continua Should. | diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..d5012dd --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,71 @@ +# Estratégia de testes + +O RetroX usa **Swift Testing** (`import Testing`), fornecido pelo toolchain Swift 6. +A suíte usa `@Test`, testes parametrizados, `#expect`, `#require`, traits e funções async. +Não é necessário acrescentar outro framework para testar os protocolos atuais. + +## Camadas implementadas + +| Camada | Comando | O que comprova | +|---|---|---| +| Unitários e mocks | `scripts/test.sh` | Regras, argumentos, persistência em diretórios temporários, parsing, falhas e interações com dependências simuladas | +| Processo real controlado | `scripts/integration-launch.sh --process-only` | argv byte a byte, término imediato, exit codes 0/17, stream de saída encerrado e executável removido; não abre janela | +| Integração desktop | `scripts/integration-launch.sh` | Inclui LaunchServices, reutilização de instância e o processo falso; requer sessão gráfica | +| Pacote | `scripts/package-app.sh 0.0.0` seguido de `scripts/verify-package.sh 0.0.0` | ZIP extraído, checksum, executável, arquitetura, versão, ícone, recurso da marca e assinatura | +| Instalação real | `scripts/test-installed-emulator.sh pcsx2 /caminho/PCSX2.app` | Inspeção do bundle e arquitetura, mais preparação do plano do PCSX2 sem executá-lo | +| Rede real | `RX_NETWORK=1 scripts/test.sh --filter MetadataNetworkTests` | Integração opt-in com Wikipedia/Wikidata | + +O CI executa os unitários/mocks, o processo falso sem janela e a validação do pacote. +Registra o commit/toolchain e guarda os logs mesmo se os testes falharem. Não depende +de jogos, BIOS, firmware, contas externas nem instalações de emuladores. +As suítes opt-in aparecem como ignoradas no comando padrão. + +## Mocks e bibliotecas + +`FakeFileSystem` e `FakeRunningApplications` isolam disco e processos abertos. +`RecordingLauncher`/`FailingLauncher` verificam chamadas, roteamento, erros e ausência +de retries/fallbacks não solicitados. `ScriptedHTTP` é um actor por teste com respostas +ordenadas e URLs registradas: testa recuperação entre idiomas e codificação da busca +sem acessar a rede nem depender de estado global. Nenhum desses mocks chega ao app distribuído. + +[Swift Testing](https://github.com/swiftlang/swift-testing) já cobre as necessidades atuais. +[Mockable](https://github.com/Kolos65/Mockable) foi avaliado como alternativa para gerar +mocks via macros. Pode valer a pena quando o número de protocolos crescer; agora os +poucos mocks são pequenos e explícitos, sem acrescentar geração de código ou dependências +à compilação. Snapshots visuais e XCUITest seriam uma camada futura para a interface, +separada dos testes de domínio e do contrato de lançamento. + +## Emuladores reais: alcance e próximos passos + +Os comandos abaixo são somente leitura e exigem o caminho explicitamente: + +```sh +scripts/test-installed-emulator.sh pcsx2 /Applications/PCSX2.app +scripts/test-installed-emulator.sh azahar /Applications/Azahar.app +scripts/test-installed-emulator.sh ryujinx /Applications/Ryujinx.app +``` + +Azahar e Ryujinx validam apenas o contrato genérico do bundle macOS: plist, executável, +permissão e arquitetura compatível. **Não validam argumentos, boot nem integração do +RetroX com esses emuladores**, pois não há adapters implementados para eles. +O teste de PCSX2 cria um arquivo temporário vazio para preparar o plano; não tenta emulá-lo. +Um caminho configurado inválido falha; ausência de configuração deixa o teste ignorado. + +| Emulador | Situação no RetroX | Etapa necessária antes de testar boot | +|---|---|---| +| PCSX2 | Adapter e plugin implementados; mocks, processo falso e preflight de instalação | Instalação/versionamento fixos, perfil de teste isolado, BIOS e fixture de teste fornecidas, critério observável de boot e timeout com limpeza do processo | +| Azahar | Sem adapter/plugin | Implementar o contrato a partir da CLI da versão escolhida, depois fixtures homebrew e dados de sistema conforme os requisitos dessa versão | +| Ryujinx | Reconhecido no catálogo, sem adapter/plugin | Escolher explicitamente distribuição/fork e versão, verificar CLI/proveniência e requisitos de firmware/chaves, depois criar adapter e fixture | + +Testes de emulação devem rodar opt-in em Mac dedicado com sessão gráfica, GPU e versões +fixadas. Não conectar um runner pessoal com credenciais a pull requests não confiáveis. +Não baixar automaticamente ROMs, BIOS, firmware ou chaves. Um processo vivo ou uma +janela aberta não prova que o jogo iniciou: o cenário precisa observar um marcador +específico (log, frame ou sinal da fixture), encerrar apenas o processo criado e guardar +versão/arquitetura/resultado. Saves e configurações devem ficar em perfil temporário. + +Referências primárias para implementar os contratos: + +- [CLI do PCSX2](https://github.com/PCSX2/pcsx2/blob/master/pcsx2-qt/QtHost.cpp). +- [Projeto Azahar](https://github.com/azahar-emu/azahar). +- [Testes assíncronos no Swift Testing](https://docs.swift.org/latest/documentation/testing/testing-asynchronous-code/). diff --git a/docs/adr/0001-swiftui-appkit.md b/docs/adr/0001-swiftui-appkit.md index a8f5ad9..0ca41e7 100644 --- a/docs/adr/0001-swiftui-appkit.md +++ b/docs/adr/0001-swiftui-appkit.md @@ -1,6 +1,6 @@ # ADR-0001 — SwiftUI como UI principal e AppKit seletivo -**Status:** Proposto | **Data:** 2026-10-27 +**Status:** Aceito | **Data:** 2026-10-27 ## Contexto Precisamos de uma aplicação nativa macOS com experiência visual de console, navegação por mouse/teclado/controle e integração com janelas e processos. @@ -13,3 +13,7 @@ Integração macOS e rápida iteração visual, porém foco avançado, entrada d ## Alternativas AppKit-only (mais boilerplate); renderizador custom com Metal (complexidade injustificada no MVP); frontend web (não atende objetivo nativo). + +## Emenda (2026-10-27) +- View models são `@Observable` e isolados em `@MainActor`; regras de negócio ficam no target `RetroXCore`, sem SwiftUI. +- Foco e teclado usam as APIs do macOS 14: `@FocusState`, `.focusable`, `.onKeyPress` e `.onMoveCommand`. diff --git a/docs/adr/0002-distribuicao-e-sandbox.md b/docs/adr/0002-distribuicao-e-sandbox.md index 92f0d3e..337d6e7 100644 --- a/docs/adr/0002-distribuicao-e-sandbox.md +++ b/docs/adr/0002-distribuicao-e-sandbox.md @@ -1,6 +1,6 @@ # ADR-0002 — Distribuição direta inicialmente, sem App Sandbox -**Status:** Proposto | **Data:** 2026-10-27 +**Status:** Aceito | **Data:** 2026-10-27 ## Contexto Precisamos abrir aplicativos externos com caminho da ROM/argumentos e acessar diretórios escolhidos pelo usuário. Apple documenta que `NSWorkspace.OpenConfiguration.arguments` é ignorado quando o processo chamador está sandboxed; acesso/execução de programas externos também enfrenta restrições de sandbox. @@ -17,3 +17,8 @@ Mac App Store sandboxed (não validada para o launcher); helper privilegiado (co ## Referências - https://developer.apple.com/documentation/appkit/nsworkspace/openconfiguration/arguments - https://developer.apple.com/documentation/security/accessing-files-from-the-macos-app-sandbox + +## Emenda (2026-10-27) +- Sem sandbox, ler pastas em Documentos, Mesa, Downloads ou volumes removíveis ainda gera prompt de privacidade (TCC) atribuído ao RetroX. O onboarding explica isso, e o estado `denied` de uma fonte orienta o usuário até os Ajustes do Sistema. O app nunca pede Acesso Total ao Disco. +- Nenhum entitlement de Apple Events: o RetroX não automatiza outros apps. +- Assinatura Developer ID e notarização dependem de uma conta Apple Developer do usuário e ficam fora do MVP técnico. Até lá, os builds locais usam assinatura ad hoc. diff --git a/docs/adr/0003-emulator-adapters.md b/docs/adr/0003-emulator-adapters.md index 32a4838..ad6e2ba 100644 --- a/docs/adr/0003-emulator-adapters.md +++ b/docs/adr/0003-emulator-adapters.md @@ -1,6 +1,6 @@ # ADR-0003 — Adapters de emulador com validação e launch tipados -**Status:** Proposto | **Data:** 2026-10-27 +**Status:** Aceito | **Data:** 2026-10-27 ## Contexto Emuladores possuem executáveis, formatos e argumentos diferentes. Um launcher genérico baseado em string de shell é frágil e inseguro. @@ -17,3 +17,8 @@ Um switch gigante por console (evolui mal); comando livre sem validação (arris ## Referências - https://github.com/PCSX2/pcsx2-net-www/blob/main/docs/advanced/cli.md - https://github.com/dolphin-emu/dolphin/blob/master/Readme.md + +## Emenda (2026-10-27) +- O adapter não executa nada: ele produz um `LaunchPlan` puro (app, executável, argumentos, estratégia). O plano é testável sem processo real e é o que o modo simulado exibe. +- A escolha entre `NSWorkspace` e `Process` está no ADR-0011 (NSWorkspace por padrão), com a evidência do spike `docs/spikes/0001-launch-pcsx2-macos.md`. +- Política para emulador já aberto: o RetroX não encerra o emulador e não cria nova instância por padrão; oferece trazê-lo para frente. diff --git a/docs/adr/0004-biblioteca-nao-destrutiva.md b/docs/adr/0004-biblioteca-nao-destrutiva.md index 125b6ff..b34c910 100644 --- a/docs/adr/0004-biblioteca-nao-destrutiva.md +++ b/docs/adr/0004-biblioteca-nao-destrutiva.md @@ -1,6 +1,6 @@ # ADR-0004 — Biblioteca indexada não destrutiva -**Status:** Proposto | **Data:** 2026-10-27 +**Status:** Aceito | **Data:** 2026-10-27 ## Contexto ROMs podem estar em múltiplos discos, diretórios e mídias removíveis. Copiar tudo gera uso de espaço e risco de perda de dados. @@ -13,3 +13,8 @@ Importação rápida e menor custo de armazenamento, mas requer controle de arqu ## Alternativas Copiar toda a biblioteca (uso de disco elevado); symlinks gerenciados (fragilidade); reescrever a estrutura de origem (risco desnecessário). + +## Emenda (2026-10-27) +- A fonte guarda um bookmark comum (sem security scope) da pasta raiz, além do último caminho conhecido. Cada jogo guarda só o caminho relativo à fonte. +- `sourceOffline` (volume desmontado, bookmark irresolúvel) é diferente de `missing` (arquivo sumiu de uma fonte acessível). Fonte offline nunca marca jogos como ausentes. +- Fontes sobrepostas (uma dentro da outra) são bloqueadas ao adicionar. diff --git a/docs/adr/0005-swiftdata-e-modelos.md b/docs/adr/0005-swiftdata-e-modelos.md index 402ea3f..70525f9 100644 --- a/docs/adr/0005-swiftdata-e-modelos.md +++ b/docs/adr/0005-swiftdata-e-modelos.md @@ -1,6 +1,6 @@ # ADR-0005 — SwiftData para persistência local do MVP -**Status:** Proposto | **Data:** 2026-10-27 +**Status:** Aceito | **Data:** 2026-10-27 ## Contexto Precisamos persistir jogos, consoles, fontes, perfis de emuladores, favoritos e metadados sem servidor. @@ -13,3 +13,8 @@ Baixa dependência externa e integração com SwiftUI; exigirá testes de migra ## Alternativas SQLite direto/GRDB (mais controle, dependência e código); JSON único (escalabilidade/consistência fracas); Core Data (mais boilerplate). + +## Emenda (2026-10-27) +- O ambiente atual só tem Command Line Tools, e nele `@Model` não compila (o plugin `SwiftDataMacros` não existe nas CLT). Decisão do usuário: instalar o Xcode antes da Fase 2. Até lá, o target `RetroXPersistence` não entra no pacote. +- `VersionedSchema` desde a v1; escrita só por um `@ModelActor`; leitura na UI por `@Query`. +- **Pendente (gate da Fase 1):** manter macOS 14 ou subir para macOS 15. A favor do 15: `#Unique` composto, `#Index` e um SwiftData mais maduro. No 14, a unicidade vem de um `locationKey` com `@Attribute(.unique)`. diff --git a/docs/adr/0006-offline-first-metadata.md b/docs/adr/0006-offline-first-metadata.md index 9370dab..440ebcb 100644 --- a/docs/adr/0006-offline-first-metadata.md +++ b/docs/adr/0006-offline-first-metadata.md @@ -1,6 +1,6 @@ # ADR-0006 — Offline-first; metadata e capas opcionais -**Status:** Proposto | **Data:** 2026-10-27 +**Status:** Aceito | **Data:** 2026-10-27 ## Contexto O launcher deve ser útil sem contas/API/network, e provedores de capa podem fornecer correspondências incorretas ou ter limites de uso. diff --git a/docs/adr/0007-gamecontroller-e-acessibilidade.md b/docs/adr/0007-gamecontroller-e-acessibilidade.md index 482c9ac..76ec131 100644 --- a/docs/adr/0007-gamecontroller-e-acessibilidade.md +++ b/docs/adr/0007-gamecontroller-e-acessibilidade.md @@ -1,6 +1,6 @@ # ADR-0007 — Navegação por controle e acessibilidade no sistema de foco -**Status:** Proposto | **Data:** 2026-10-27 +**Status:** Aceito | **Data:** 2026-10-27 ## Contexto A experiência de console depende de navegação previsível por controle, mas precisa continuar totalmente funcional em teclado e mouse. @@ -13,3 +13,15 @@ Melhor consistência, ao custo de lidar com reconexão, múltiplos gamepads e re ## Alternativas Depender só de focus engine automática (pode não bastar); handlers soltos em cada View (comportamento divergente). + +## Emenda (2026-10-27) +- `GCController.shouldMonitorBackgroundEvents` fica `false` (default desde o macOS 11.3), de forma explícita: com o emulador em primeiro plano, o RetroX não recebe input do controle. +- O `InputCoordinator` só atua no Console Mode. No Library Mode vale o foco nativo do macOS. + +## Emenda (2026-10-28, tarde) +- O controle navega o app inteiro: perfis, Início (as três visualizações), Biblioteca, Emuladores, Ajustes, detalhes e a partida. Só a edição de texto (nome de perfil, busca) continua exigindo teclado. +- Mapa fixo por posição: baixo confirma (direita no layout Nintendo ou com "inverter"), direita volta, esquerda abre detalhes, cima favorita, LB/RB trocam prateleira ou console, gatilhos trocam de aba, Start abre Ajustes, View/Create/− abre Emuladores. +- Presets de glifos: Automático (o próprio controle), Xbox, PlayStation (DualSense) e Nintendo Switch (`ControllerPreset`, testado). +- Os Ajustes saíram da janela separada do macOS e viraram uma tela do app, para serem alcançáveis pelo controle. ⌘, continua abrindo os Ajustes. +- Níveis de animação por perfil (completas, reduzidas, desligadas) somam-se ao Reduzir Movimento do macOS: com qualquer um dos dois, nada cresce, desliza ou inclina. + diff --git a/docs/adr/0008-performance-e-scan.md b/docs/adr/0008-performance-e-scan.md index 7186eeb..c7f1430 100644 --- a/docs/adr/0008-performance-e-scan.md +++ b/docs/adr/0008-performance-e-scan.md @@ -1,6 +1,6 @@ # ADR-0008 — Scan incremental e imagens sob demanda -**Status:** Proposto | **Data:** 2026-10-27 +**Status:** Aceito | **Data:** 2026-10-27 ## Contexto Bibliotecas podem ter milhares de entradas. O app não deve congelar por enumeração de diretórios, hash de ROMs grandes ou decodificação de thumbnails. @@ -13,3 +13,7 @@ UI responsiva, mas mais estado de progresso e complexidade de atualização/coer ## Alternativas Scans completos síncronos, full-size artwork no grid ou pre-hash de todas as ROMs (lentos). + +## Emenda (2026-10-27) +- O scanner usa `FileManager.enumerator` com pré-carga de resource keys, pula arquivos ocultos e packages, e publica em lotes de cerca de 200 itens. +- Thumbnails são geradas com ImageIO (`CGImageSourceCreateThumbnailAtIndex`), com `NSCache` em memória e cache em disco em `Caches/RetroX/Thumbnails`. diff --git a/docs/adr/0009-seguranca-e-diagnosticos.md b/docs/adr/0009-seguranca-e-diagnosticos.md index 4e41ee7..4974b70 100644 --- a/docs/adr/0009-seguranca-e-diagnosticos.md +++ b/docs/adr/0009-seguranca-e-diagnosticos.md @@ -1,6 +1,6 @@ # ADR-0009 — Segurança, logs e observabilidade locais -**Status:** Proposto | **Data:** 2026-10-27 +**Status:** Aceito | **Data:** 2026-10-27 ## Contexto O app pode iniciar programas externos escolhidos pelo usuário e ler estruturas de pastas pessoais. @@ -13,3 +13,7 @@ Menos risco e maior previsibilidade; troubleshooting requer logs locais bem deli ## Alternativas Logging irrestrito de paths, execução de comandos livres e telemetria automática (rejeitados). + +## Emenda (2026-10-27) +- Os argumentos de um launch são logados com caminhos marcados como `.private`. +- Argumentos extras do usuário são tokenizados ao salvar, guardados como array e validados contra uma lista de flags que o adapter gerencia (por exemplo `--`, `-batch`, `--exec`). diff --git a/docs/adr/0010-build-swiftpm-first.md b/docs/adr/0010-build-swiftpm-first.md new file mode 100644 index 0000000..ff91455 --- /dev/null +++ b/docs/adr/0010-build-swiftpm-first.md @@ -0,0 +1,27 @@ +# ADR-0010 — Build SwiftPM-first + +**Status:** Aceito | **Data:** 2026-10-27 + +## Contexto +A máquina de desenvolvimento tem só as Command Line Tools (Swift 6.4, SDK macOS 27), sem Xcode. Nas CLT, SwiftUI, `@Observable` e Swift Testing compilam, mas a macro `@Model` do SwiftData não (o plugin `SwiftDataMacros` não existe nas CLT). Também não há `xcodebuild`, XCUITest nem Instruments. O usuário vai instalar o Xcode antes da Fase 2. + +## Decisão +O código vive num pacote SwiftPM (`Package.swift`), que compila com `swift build` e testa com `swift test` tanto nas CLT quanto no Xcode (que abre o pacote diretamente). + +- `RetroXCore`: regras de negócio, sem SwiftUI e sem SwiftData. Compila e testa só com as CLT. +- `RetroXApp`: executável SwiftUI. Para rodar como app de verdade (Dock, ativação, Info.plist), o script `scripts/bundle-app.sh` monta `RetroX.app` a partir do binário e assina ad hoc. +- `RetroXPersistence` (SwiftData) só entra no pacote quando o Xcode estiver instalado (Fase 2). +- Ferramentas de desenvolvimento (`rx-launch-probe`, `rx-fake-emulator`) são targets executáveis do mesmo pacote e não fazem parte do app distribuído. + +Um projeto `.xcodeproj` (gerado por XcodeGen, com consentimento) só será criado se XCUITest, asset catalogs ou o fluxo de distribuição exigirem. + +## Consequências +O núcleo é testável sem Xcode e o repositório não carrega um `.xcodeproj` difícil de revisar. Em troca, o bundle do app é montado por script, e recursos como asset catalog e String Catalog podem exigir passos extras até existir um projeto Xcode. + +Peculiaridades das CLT encontradas na Sprint 1 (Swift 6.4, backend `swiftbuild`): +- A descoberta do plugin de macros do Swift Testing é intermitente (2 falhas em 3 builds limpos). Os testes rodam por `scripts/test.sh`, que passa `-plugin-path` explicitamente quando o toolchain ativo é o das CLT. +- O backend antigo (`--build-system native`) não encontra o módulo `Testing` nas CLT. +- No SDK do macOS 27, o `@State` do SwiftUI é uma macro (`SwiftUIMacros.StateMacro`), e as CLT não trazem o plugin `SwiftUIMacros` (no SDK 26.5 ainda era property wrapper). `@Observable` e `@Bindable` funcionam. Até o Xcode ser instalado, a UI evita `@State`: o estado de tela fica no view model `@Observable`. Isso reforça que as Fases 2 e 3 dependem do Xcode. + +## Alternativas +`.xcodeproj` escrito à mão sem Xcode (frágil); Tuist ou XcodeGen desde já (ferramenta externa sem necessidade imediata); esperar o Xcode para começar (atrasa a prova técnica do launch, que não depende dele). diff --git a/docs/adr/0011-estrategia-de-launch.md b/docs/adr/0011-estrategia-de-launch.md new file mode 100644 index 0000000..b0618fa --- /dev/null +++ b/docs/adr/0011-estrategia-de-launch.md @@ -0,0 +1,27 @@ +# ADR-0011 — Estratégia de launch: NSWorkspace por padrão, Process como alternativa + +**Status:** Aceito | **Data:** 2026-10-27 + +## Contexto +O ADR-0003 deixou para um spike a escolha entre `NSWorkspace.openApplication` e `Process` para abrir cada emulador. O spike `docs/spikes/0001-launch-pcsx2-macos.md` abriu o PCSX2 2.9.144 real com caminhos difíceis pelas duas estratégias e checou o resultado no log do próprio emulador. + +## Decisão +- **Padrão para emuladores com app (`.app`): `NSWorkspace`** (`WorkspaceLauncher`), com `arguments` = argv do adapter, `activates = true`, `createsNewApplicationInstance = false`, `addsToRecentItems = false`. +- **`Process`** (`FoundationProcessLauncher`) continua disponível por perfil (`EmulatorProfile.strategy`) para diagnóstico e para emuladores sem `.app`. stdout e stderr vão para `/dev/null` ou para um arquivo escolhido, nunca para um pipe sem leitor. +- O término é observado por kqueue (`DispatchSource` de processo), que funciona para as duas estratégias e não depende de run loop. Exit status só existe com `Process`. +- Emulador já aberto: a validação recusa o launch (`emulatorAlreadyRunning`) e oferece trazê-lo para frente. O RetroX não encerra o emulador nem abre outra instância por padrão. +- PCSX2: argv `["-batch", "--", ]`. A tela cheia segue a configuração do próprio emulador, a menos que o perfil force `-fullscreen` ou `-nofullscreen`. + +## Por quê +- As duas estratégias entregaram o argv byte a byte e abriram o jogo. O desempate vem do resto: + - Com `NSWorkspace`, o emulador é lançado pelo LaunchServices: ganha ativação e Dock normais, e responde pelos próprios prompts de privacidade (TCC). Com `Process`, ele vira filho do RetroX e esses prompts são atribuídos ao RetroX. + - A vantagem do `Process` (exit status e stdout) pesou pouco: o stdout do PCSX2 é quase vazio e o log útil fica no `emulog.txt` dele. +- A documentação da Apple e o teste com emulador falso mostram que, com o app já aberto, o LaunchServices descarta os novos argumentos. Daí a recusa explícita em vez de um launch que "não faz nada". + +## Consequências +- Sem exit status na estratégia padrão: a detecção de falha usa a janela de saída precoce (5 s) do `SessionOutcome`. +- Cada novo adapter deve repetir um spike curto (argv + log do emulador) antes de virar padrão. +- Se o RetroX um dia for para sandbox, `arguments` passa a ser ignorado (ADR-0002) e esta decisão precisa ser revista. + +## Alternativas +`open -a … --args` via `Process` (mesmo efeito do `NSWorkspace`, com um processo intermediário); nova instância sempre (`createsNewApplicationInstance = true`), que arrisca conflito de memory cards e de configuração; Apple Events de abrir documento (não passam flags como `-batch`). diff --git a/docs/adr/0012-deteccao-de-sistema.md b/docs/adr/0012-deteccao-de-sistema.md new file mode 100644 index 0000000..b1b6be0 --- /dev/null +++ b/docs/adr/0012-deteccao-de-sistema.md @@ -0,0 +1,24 @@ +# ADR-0012 — Detecção de sistema por fonte, cabeçalho e extensão + +**Status:** Aceito (assinaturas a validar com fixtures na Fase 2) | **Data:** 2026-10-27 + +## Contexto +A mesma extensão serve a vários consoles: `.iso` pode ser PS2, GameCube ou Wii; `.rvz` pode ser GameCube ou Wii. O RF-02 proíbe presumir o console só pela extensão. Hash integral é caro e não é exigido (ADR-0008). + +## Decisão +O `SystemDetector` decide o sistema de cada arquivo nesta ordem: + +1. **Dica da fonte:** o usuário pode declarar o sistema de uma pasta-fonte. Se a extensão é suportada por esse sistema, ela vale. +2. **Cabeçalho:** leitura de no máximo 64 KB do início do arquivo, sem hash. Assinaturas candidatas, a confirmar com fixtures sintéticas na Fase 2: + - ISO 9660 com `CD001` em `0x8001` e identificador de sistema `PLAYSTATION` → PS2 (o RetroX não suporta PS1 no MVP); + - magic GameCube `0xC2339F3D` em `0x1C`; + - magic Wii `0x5D1C9EA3` em `0x18`. +3. **Extensão inequívoca:** quando só um sistema do catálogo aceita a extensão. + +Se nenhuma regra decide, o arquivo entra como `needsReview` e o usuário escolhe. O método usado fica registrado no jogo (`detectionMethod`), e a escolha manual sempre prevalece. + +## Consequências +Menos falsos positivos e nenhum custo de hash. Formatos comprimidos (`chd`, `rvz`) dependem da dica da fonte ou de escolha manual até haver leitura específica do formato. + +## Alternativas +Só extensão (atribui errado `.iso` de outros consoles); hash completo contra banco de dados (lento e exige base externa); perguntar sempre (onboarding cansativo). diff --git a/docs/adr/0013-emuladores-como-plugins.md b/docs/adr/0013-emuladores-como-plugins.md new file mode 100644 index 0000000..969ec52 --- /dev/null +++ b/docs/adr/0013-emuladores-como-plugins.md @@ -0,0 +1,26 @@ +# ADR-0013 — Emuladores como plugins com pasta raiz + +**Status:** Aceito | **Data:** 2026-10-28 + +## Contexto +A primeira interface pedia o caminho do `.app` do emulador e o de cada ROM. Na prática, quem usa emuladores já organiza tudo numa pasta por emulador (o app, `roms/`, `covers/`, a BIOS), e o próprio emulador já conhece as pastas de jogos dele. Pedir arquivo por arquivo era a usabilidade errada, e o seletor ainda perdia a escolha (ver o commit da nova interface). + +## Decisão +- Cada emulador é um **plugin** (`EmulatorPlugin`) que o usuário **ativa** na tela Emuladores e para o qual escolhe uma **pasta raiz**: a pasta que guarda o app do emulador e as pastas de jogos. +- O plugin descobre, só lendo: + - o app (direto na raiz ou uma subpasta abaixo; com várias versões, a mais nova); + - as pastas de jogos (`roms`, `games`, `jogos`, `isos`…) e as ROMs soltas na raiz; + - as capas (`covers`…). +- O plugin nativo do PCSX2 também lê, **sem alterar**, a configuração do próprio PCSX2 (`inis/PCSX2.ini`, inclusive no modo portátil): as pastas da lista de jogos (`Paths`, `RecursivePaths`), a pasta de capas e a BIOS escolhida. +- O serial do disco (`SYSTEM.CNF` da ISO) é lido para casar as capas que o PCSX2 baixa, que ele nomeia pelo serial. +- O plugin entrega o `EmulatorAdapter` (ADR-0003) que abre cada jogo. O perfil por sistema do PRD vira "o plugin ativo para aquele sistema"; o override por jogo continua previsto (RF-13). +- Configuração dos plugins e histórico de jogos (favoritos, últimas partidas) ficam em JSON em `~/Library/Application Support/RetroX/` até a Fase 2. Nessa fase a biblioteca vai para o SwiftData (ADR-0005), com ID persistente e detecção de arquivo movido. + +## Consequências +- O onboarding encolhe para "ativar o emulador e escolher a pasta dele". +- Até a Fase 2, o ID de um jogo é o caminho do arquivo: mover uma ROM de lugar perde favorito e histórico dela. +- Cada plugin novo (Dolphin, por exemplo) precisa do seu próprio conhecimento de pastas e configuração, e de um spike de launch (ADR-0011). +- Plugins de vários sistemas (GameCube e Wii no Dolphin) dependem da detecção do ADR-0012. + +## Alternativas +Perfis por sistema com caminhos manuais, como no PRD original (mais passos, sem aproveitar o que o emulador já sabe); importar a lista de jogos de dentro do cache binário do PCSX2 (formato interno, sem garantia entre versões); copiar as ROMs para uma biblioteca própria (proibido pelo ADR-0004). diff --git a/docs/adr/0014-metadados-wikipedia.md b/docs/adr/0014-metadados-wikipedia.md new file mode 100644 index 0000000..b0bdcae --- /dev/null +++ b/docs/adr/0014-metadados-wikipedia.md @@ -0,0 +1,27 @@ +# ADR-0014 — Informações de jogos: banco do emulador offline e Wikipedia sob demanda + +**Status:** Aceito | **Data:** 2026-10-28 + +## Contexto +O usuário pediu para puxar informações dos jogos de uma fonte pública, como o Playnite faz. O ADR-0006 exige offline-first, fonte opcional, cache, escolha manual do resultado e atenção a termos e licenças. + +Opções avaliadas: +- **IGDB** (o padrão do Playnite): bom para jogos, mas exige credenciais de desenvolvedor da Twitch (client ID e secret) de cada usuário. +- **TheGamesDB, ScreenScraper, RAWG**: exigem chave ou conta. +- **Wikipedia e Wikidata**: públicos, sem chave. Texto da Wikipedia em CC BY-SA (exige atribuição); dados do Wikidata em CC0. +- **Banco do próprio emulador**: o PCSX2 traz `GameIndex.yaml` no app (serial → nome oficial, região, compatibilidade). Offline e local. + +## Decisão +1. **Offline e automático:** o plugin do PCSX2 lê o `GameIndex.yaml` do app para os seriais da biblioteca. O nome oficial aparece como subtítulo, e região e compatibilidade nos detalhes. O título exibido continua sendo o do arquivo, porque mods e traduções não batem com o nome oficial (ex.: "GT 4 Spec II" tem o serial de "Gran Turismo 4 [Online Public Beta]"). +2. **Online, só por ação do usuário:** em Detalhes, "Buscar na Wikipedia" pesquisa (português, depois inglês) e lista resultados. O usuário escolhe o certo, e só então o resumo é baixado e guardado em `games.json`. O Wikidata completa ano, desenvolvedora, editora e gênero quando responde; se estiver fora do ar, fica só o resumo. +3. A fonte e a licença ficam visíveis ("Fonte: Wikipedia (pt) · CC BY-SA", com link). As requisições usam um User-Agent identificado, como pede a política da Wikimedia. +4. **Capas não são baixadas da internet.** Capas de jogos em geral não são livres. O RetroX usa as capas que o usuário tem (pastas do emulador, capas que o PCSX2 baixa, ou uma imagem escolhida em "Trocar capa"). +5. O contrato fica aberto para outros provedores. O IGDB pode entrar depois, como opção para quem fornecer as próprias credenciais. + +## Consequências +- Funciona sem conta e sem chave. Sem rede, tudo continua funcionando, menos a busca, que avisa. +- Jogos obscuros podem não ter página na Wikipedia; a busca aceita editar o termo. +- Em 2026-10-28 o Wikidata estava fora do ar (503) durante o desenvolvimento. O desenho já trata isso, e o teste `wikidataDown` cobre o caso. + +## Alternativas +IGDB por padrão (exige credenciais por usuário); scraping de sites de capas (licenças e termos); buscar automaticamente para a biblioteca inteira (viola a escolha manual do ADR-0006 e gera associações erradas). diff --git a/docs/design/brand-spec.md b/docs/design/brand-spec.md new file mode 100644 index 0000000..c6724cf --- /dev/null +++ b/docs/design/brand-spec.md @@ -0,0 +1,86 @@ +# Brand spec — RetroX ("Portátil quente") + +Direção escolhida em 2026-10-28 entre três propostas (Portátil quente, Editorial de caixa, Console funcional). A usabilidade se inspira na filosofia de launchers de portáteis como o iiSU: palco do jogo em foco, prateleira de capas, controle primeiro. A identidade é própria: nenhum logo, tela, som ou asset do iiSU, da Sony ou da Nintendo é usado. As capas exibidas são as que o usuário tem no próprio disco. + +Os valores abaixo são a fonte da verdade em `Sources/RetroXCore/Design/Palette.swift`, e o contraste é verificado por teste (`PaletteTests`). + +## Marca +- **Símbolo:** a Rayo, no tile arredondado do ícone (`docs/design/rayo/`). Nunca em círculo, para não ser confundida com a foto de perfil. +- **Nome:** "Retro" na cor do texto e "X" na cor de destaque, em SF Pro Rounded Heavy. +- **Composição:** medidas pela altura das maiúsculas; regras, cores e arquivos em `docs/design/logo/README.md`. + +## Temas +O RetroX tem 12 temas, escolhidos por perfil, cada um com variante clara e escura: **RetroX** (a paleta abaixo, com 4 acentos), **Rayo Paradise** (as cores da Rayo: noite em índigo `#191A2E` com o rosa `#FEB5B8`; dia no céu `#EDF3FF` com a framboesa `#D63A62`), Gruvbox, Catppuccin (Latte/Mocha), Tokyo Night (Day/Night), Nord, Solarized, Dracula (Alucard/Dracula), Rosé Pine (Dawn/Main), Everforest, Kanagawa (Lotus/Wave) e One. Os esquemas conhecidos usam as cores oficiais de cada projeto, com ajustes mínimos onde a cor original não atingia AA. Quase sempre o ajuste foi no texto secundário; em Catppuccin Latte, Tokyo Night Day, Solarized e Everforest claros, o texto principal ficou um pouco mais escuro para manter a hierarquia. Os valores estão em `Sources/RetroXCore/Design/Themes.swift`, e `ThemeTests` confere contraste e hierarquia de todos. + +Cada tema define oito tokens: fundo, superfície, elevado, texto, texto secundário, divisória, acento e texto sobre o acento. + +## Cores (tema RetroX) + +### Neutros (quentes, nunca preto ou branco puros) +| Token | Claro | Escuro | Uso | +|---|---|---|---| +| background | #F4F0EA | #1B1916 | Fundo das telas | +| surface | #FFFDF9 | #262320 | Cartões, barra de comandos, folhas | +| raised | #EAE4DB | #312D29 | Botões secundários, teclas, chão da prateleira | +| textPrimary | #1E1B18 | #F3EEE7 | Texto principal | +| textSecondary | #6A625A | #ADA59B | Legendas, metadados | +| divider | #DDD5CA | #3B3632 | Separadores | + +### Cor de tema (uma por vez, escolhida nos Ajustes) +| Tema | Preenchimento claro | Preenchimento escuro | +|---|---|---| +| Coral (padrão) | #C7472A | #F07A57 | +| Âmbar | #A85E12 | #E9A23B | +| Sálvia | #4F7A52 | #8DBB8F | +| Ardósia | #44657F | #8DB3CF | + +Texto sobre o preenchimento: #FFFDF9 no claro, #1B1916 no escuro. A cor de tema aparece só em preenchimentos (Jogar, chave ligada), anéis de foco, halo da capa em foco e marcadores. **Nunca em texto pequeno.** + +### Capas geradas (quando não há imagem) +Tom por sistema (PS2: #2E3742), texto #F3EEE7. Sem cores de marca de console. + +### Contraste mínimo (testado) +Texto ≥ 4,5:1 sobre background, surface e raised, nos dois modos. Texto sobre a cor de tema ≥ 4,5:1. Anel de foco ≥ 3:1 sobre o fundo. + +## Tipografia +SF Pro Rounded para títulos (tom de console), SF Pro para o resto. No máximo quatro tamanhos por tela. + +| Nível | Tamanho | Peso | +|---|---|---| +| Display | 34 pt | Bold, rounded | +| Heading | 20 pt | Semibold, rounded | +| Body | 15 pt | Regular (Semibold para ênfase) | +| Caption | 12 pt | Medium; em caixa alta com tracking 1,4 nas linhas de sistema | + +## Espaçamento e forma +- Grade de 8 pt: 4, 8, 16, 24, 32, 48. Margem lateral das telas: 48 pt. +- Raios: 8, 12 (capas e cartões pequenos), 16 (cartões grandes). Botões são cápsulas com altura mínima de 44 pt. +- Capas sempre na proporção 5:7. + +## Detalhe de assinatura +Na prateleira, a capa em foco sobe 10%, ganha um halo na cor de tema e se reflete no "chão" da prateleira. Com Reduzir Movimento, fica só o anel de foco: nada cresce nem se desloca. + +## Movimento +- Foco: `snappy` (0,32 s, quique quase imperceptível). +- Hover: `smooth` (0,24 s), com elevação leve (escala 1,03–1,04). +- Troca de aba: o conteúdo novo entra deslizando 48 pt pelo lado da aba escolhida (`smooth`, 0,45 s); a barra de cima fica parada. +- Entrar num perfil: a tela inicial aproxima (escala 1,04 → 1). +- Cartões (detalhes, perfil): crescem de 0,94 e sobem 16 pt enquanto o fundo escurece. +- Carrossel: as capas vizinhas encolhem 16% por posição e inclinam 38° em 3D. +- Fundo ambiente: a capa em foco, desfocada, troca em 0,7 s. +- Abertura: anel desenhado em 0,9 s em volta do símbolo, que entra com mola; o nome entra letra a letra (60 ms entre letras). +- O perfil escolhe o nível (completas, reduzidas, desligadas) e a velocidade (calma ×1,35, normal, rápida ×0,7). Com animações reduzidas ou o Reduzir Movimento do macOS, só esmaece: nada cresce, desliza nem inclina. + +## Telas +- **Perfis** ("Quem vai jogar?", avatares de 128 pt com anel de foco). +- **Início**, em três visualizações à escolha do perfil: Prateleira (palco + prateleira), Carrossel e Grade. +- **Biblioteca**: Capas pequenas 96 pt, Capas 128 pt, Capas grandes 184 pt, Capas e nomes 148 pt, Lista. +- **Emuladores**: ícone do app instalado, selo de estado e blocos de resumo. +- **Ajustes**: seções à esquerda, linhas `‹ valor ›` à direita. +- **Detalhes**: cartão de 880 × 620. + +## Texto +O perfil escolhe a fonte (Arredondada, Padrão, Serifada, Monoespaçada) e o tamanho (90%, 100%, 112%, 125%). Na Arredondada, só os títulos são arredondados; o corpo fica na fonte padrão. + +## Voz +Português do Brasil, direto e calmo. Mensagens de erro dizem o que aconteceu e oferecem a ação que resolve. diff --git a/docs/design/logo/README.md b/docs/design/logo/README.md new file mode 100644 index 0000000..0ec681e --- /dev/null +++ b/docs/design/logo/README.md @@ -0,0 +1,67 @@ +# Kit da marca RetroX + +A marca é a **Rayo** (o tile) e o nome **RetroX**, com o "X" em gradiente no escuro e sólido no claro. Tudo aqui é gerado a partir de +`docs/design/rayo/rayo.png` por `swift scripts/brand-kit.swift`; para mudar o kit, mude o original ou o script, +nunca os PNGs à mão. + +![Prancha do kit](retrox-kit.png) + +## Arquivos + +| Arquivo | Uso | +|---|---| +| `retrox-icon-1024.png` | Ícone na grade do macOS (corpo 824/1024, cantos transparentes). O `.icns` fica em `Support/RetroX.icns` | +| `retrox-tile-512.png` | Só o tile, sem margem: avatares, favicons, redes | +| `retrox-lockup-on-dark.png` / `-on-light.png` | Assinatura horizontal (padrão; barra superior e abertura do app) | +| `retrox-stacked-on-dark.png` / `-on-light.png` | Assinatura empilhada: espaços estreitos e verticais (README, redes) | +| `retrox-wordmark-on-dark.png` / `-on-light.png` | Só o nome, quando a Rayo já está por perto | +| `retrox-x-on-dark.png` / `-on-light.png` | Só o X: detalhes, carregamento, selos | + +## Alinhamento + +Toda medida vem da **altura das maiúsculas (C)** do nome, em SF Pro Rounded Heavy com tracking −1,5%. + +- **Horizontal:** tile = 2C. A faixa das maiúsculas (da linha de base ao topo do "R") fica centrada no tile. O vão + entre o tile e a tinta do "R" é 0,75C. +- **Empilhada:** tile = 3C, centrado sobre o nome. Do pé do tile ao topo das maiúsculas: 1C. +- **"o→X":** o X tem recuo lateral largo, então o par leva um aperto extra de −4,5% do corpo. Assim todos os vãos + entre as letras ficam iguais. +- **O X:** a própria letra da fonte (sem contorno, sombra ou recorte). +- **Sobre o escuro:** gradiente do canto de cima à esquerda ao de baixo à direita. A cor inicial segura até 30% e + então caminha até a final, misturada em OKLCH (o meio fica vivo, sem o cinza da mistura em RGB). +- **Sobre o claro:** framboesa sólida. O gradiente framboesa → azul, testado, ficava roxo e pesado sobre o claro. +- **Respiro:** no mínimo 1C livre em volta da assinatura. +- **Tamanho mínimo:** horizontal com nome a partir de 16 pt; abaixo disso, só o tile. + +No app, `BrandLockup`, `BrandWordmark`, `BrandX` e `BrandMark` (`Sources/RetroXApp/DesignSystem/Brand.swift`) aplicam +as mesmas regras. Lá o X usa a cor de destaque do tema: sólida no claro e, no escuro, em gradiente até o céu da Rayo +(`#7BADFD`). No tema Rayo Paradise isso reproduz exatamente as cores do kit. `ThemeTests` confere que o X tem pelo +menos 3:1 sobre o fundo em todos os temas. + +## Cores + +| Nome | Hex | Uso | +|---|---|---| +| Rosa Rayo → Céu Rayo | `#FEB5B8` → `#7BADFD` | Gradiente do X sobre o escuro (≥ 7,7:1 sobre `#1B1916`) | +| Framboesa | `#D63A62` | X sólido sobre o claro (3,99:1 sobre `#F4F0EA`) | +| Tinta / Papel | `#1E1B18` / `#F3EEE7` | "Retro" sobre o claro / sobre o escuro | +| Noite / Dia | `#1B1916` / `#F4F0EA` | Fundos de referência | + +## Não fazer + +- Não recortar, girar nem trocar a ilustração do tile; não pôr a Rayo em círculo (vira foto de perfil). +- Não colorir o "Retro", não pintar de uma cor só o X sobre o escuro nem trocar a direção do gradiente. +- Não usar o gradiente do escuro sobre fundo claro (some) nem pôr gradiente no X sobre o claro. +- Não aproximar o tile do nome além de 0,75C, nem centralizar o nome pela caixa do texto (fica baixo). + +## Cabeçalho para o README do projeto + +```html +

+ + + RetroX + +

+

Seus jogos, num lugar só. Um launcher nativo para macOS.

+``` diff --git a/docs/design/logo/retrox-icon-1024.png b/docs/design/logo/retrox-icon-1024.png new file mode 100644 index 0000000..933e572 Binary files /dev/null and b/docs/design/logo/retrox-icon-1024.png differ diff --git a/docs/design/logo/retrox-kit.png b/docs/design/logo/retrox-kit.png new file mode 100644 index 0000000..fe544cf Binary files /dev/null and b/docs/design/logo/retrox-kit.png differ diff --git a/docs/design/logo/retrox-lockup-on-dark.png b/docs/design/logo/retrox-lockup-on-dark.png new file mode 100644 index 0000000..a17ad15 Binary files /dev/null and b/docs/design/logo/retrox-lockup-on-dark.png differ diff --git a/docs/design/logo/retrox-lockup-on-light.png b/docs/design/logo/retrox-lockup-on-light.png new file mode 100644 index 0000000..1f02d4b Binary files /dev/null and b/docs/design/logo/retrox-lockup-on-light.png differ diff --git a/docs/design/logo/retrox-stacked-on-dark.png b/docs/design/logo/retrox-stacked-on-dark.png new file mode 100644 index 0000000..fdf2099 Binary files /dev/null and b/docs/design/logo/retrox-stacked-on-dark.png differ diff --git a/docs/design/logo/retrox-stacked-on-light.png b/docs/design/logo/retrox-stacked-on-light.png new file mode 100644 index 0000000..0899758 Binary files /dev/null and b/docs/design/logo/retrox-stacked-on-light.png differ diff --git a/docs/design/logo/retrox-tile-512.png b/docs/design/logo/retrox-tile-512.png new file mode 100644 index 0000000..3b0d82a Binary files /dev/null and b/docs/design/logo/retrox-tile-512.png differ diff --git a/docs/design/logo/retrox-wordmark-on-dark.png b/docs/design/logo/retrox-wordmark-on-dark.png new file mode 100644 index 0000000..6289708 Binary files /dev/null and b/docs/design/logo/retrox-wordmark-on-dark.png differ diff --git a/docs/design/logo/retrox-wordmark-on-light.png b/docs/design/logo/retrox-wordmark-on-light.png new file mode 100644 index 0000000..fe964bd Binary files /dev/null and b/docs/design/logo/retrox-wordmark-on-light.png differ diff --git a/docs/design/logo/retrox-x-on-dark.png b/docs/design/logo/retrox-x-on-dark.png new file mode 100644 index 0000000..47f7153 Binary files /dev/null and b/docs/design/logo/retrox-x-on-dark.png differ diff --git a/docs/design/logo/retrox-x-on-light.png b/docs/design/logo/retrox-x-on-light.png new file mode 100644 index 0000000..6f1a0b0 Binary files /dev/null and b/docs/design/logo/retrox-x-on-light.png differ diff --git a/docs/design/rayo/rayo-portrait.png b/docs/design/rayo/rayo-portrait.png new file mode 100644 index 0000000..fb9933b Binary files /dev/null and b/docs/design/rayo/rayo-portrait.png differ diff --git a/docs/design/rayo/rayo.png b/docs/design/rayo/rayo.png new file mode 100644 index 0000000..d1386e1 Binary files /dev/null and b/docs/design/rayo/rayo.png differ diff --git a/docs/images/retrox-gruvbox.png b/docs/images/retrox-gruvbox.png new file mode 100644 index 0000000..a6e2a9d Binary files /dev/null and b/docs/images/retrox-gruvbox.png differ diff --git a/docs/images/retrox-inicio.png b/docs/images/retrox-inicio.png new file mode 100644 index 0000000..b95a231 Binary files /dev/null and b/docs/images/retrox-inicio.png differ diff --git a/docs/images/retrox-rayo-claro.png b/docs/images/retrox-rayo-claro.png new file mode 100644 index 0000000..63ed2bb Binary files /dev/null and b/docs/images/retrox-rayo-claro.png differ diff --git a/ConsoleShelf_PROMPT_COMPLETO.md b/docs/origem/ConsoleShelf_PROMPT_COMPLETO.md similarity index 100% rename from ConsoleShelf_PROMPT_COMPLETO.md rename to docs/origem/ConsoleShelf_PROMPT_COMPLETO.md diff --git a/PROMPT_CLAUDE_CODE.md b/docs/origem/PROMPT_CLAUDE_CODE.md similarity index 100% rename from PROMPT_CLAUDE_CODE.md rename to docs/origem/PROMPT_CLAUDE_CODE.md diff --git a/docs/spikes/0001-launch-pcsx2-macos.md b/docs/spikes/0001-launch-pcsx2-macos.md new file mode 100644 index 0000000..15630a0 --- /dev/null +++ b/docs/spikes/0001-launch-pcsx2-macos.md @@ -0,0 +1,46 @@ +# Spike 0001 — Abrir uma ROM de PS2 no PCSX2 para macOS + +**Data:** 2026-10-27 · **Status:** concluído · **Decisão resultante:** ADR-0011 + +## Pergunta +O PCSX2 para macOS abre diretamente uma ROM recebida por argumento, com caminhos difíceis, quando lançado pelo RetroX? Qual forma de criar o processo usar: `NSWorkspace` ou `Process`? + +## Ambiente +- Mac Apple M4, macOS 27.2, Rosetta instalado. Só Command Line Tools (Swift 6.4, SDK 27.0). +- PCSX2 v2.9.144 (`net.pcsx2.pcsx2`), executável `Contents/MacOS/PCSX2`, **x86_64 apenas** (roda via Rosetta), hardened runtime. +- Configuração do PCSX2 do usuário, não alterada: `StartFullscreen=true`, `ConfirmShutdown=true`, BIOS configurada. +- ROM: uma ISO de PS2 do próprio usuário, com espaços e parênteses no nome. +- Ferramenta: `rx-launch-probe`, que usa o mesmo `GameLauncher` do app. + +## Evidência estática (sem executar o PCSX2) +O texto de ajuda dentro do binário macOS 2.9.144 (extraído com `strings`) traz `Usage: {} [parameters] [--] [boot filename]` e as flags `-batch`, `-nogui`, `-fullscreen`, `-nofullscreen`, `-bigpicture`, `-fastboot`, `-slowboot`, `-portable`, `-datapath`, `-gamecfg`, `-elf`, `-disc`, `-bios`, `-state`, `-statefile`, `-logfile`, `-testconfig`, `-setupwizard`, `-turbo`, `-unlimited` e `--` ("use quando o nome tem espaços ou começa com hífen"). Bate com a documentação oficial da CLI, que só mostra exemplos de Windows. + +## Execuções reais +Cada execução abriu o PCSX2 com `["-batch", "--", ]`. O critério de sucesso foi a linha `isoFile open ok: ` no log do próprio PCSX2 (`emulog.txt`), comparada byte a byte com o argv, mais o serial do disco e o início da execução do jogo. As sessões foram encerradas com SIGTERM só no pid aberto pelo teste, com autorização do usuário. + +| # | Estratégia | Caminho da ROM | Log == argv (bytes) | Jogo iniciou | Término detectado | +|---|---|---|---|---|---| +| A | `workspace` | original: espaços e parênteses | sim | sim (serial SLUS-21490, ELF executando em 6,3 s) | sim, sem exit status | +| B | `process` | symlink em pasta **NFD** com `ç`, `ã`, `ñ`, CJK e emoji; nome **começando com `-`** | sim | sim (4 s) | sim, status 0 | +| C | `workspace` | symlink em pasta e nome **NFC** com acentos | sim | sim (4 s) | sim, sem exit status | +| D | — | com o PCSX2 aberto (durante A), nova validação com outra ROM | — | — | recusada: `emulatorAlreadyRunning`, ação "Trazer emulador para frente" | + +Observações: +- O PCSX2 registra exatamente o caminho recebido (o do symlink, não o alvo), sem normalizar Unicode. NFD e NFC funcionaram. +- O `--` é o que permite o nome iniciado por hífen; sem ele, o PCSX2 trataria o nome como opção. +- O PCSX2 trata SIGTERM como Ctrl+C: "Received CTRL+C, attempting graceful shutdown". Saiu em cerca de 1 s, com status 0. +- Com a estratégia `process`, o stdout do PCSX2 é quase vazio (76 bytes). O log útil continua em `emulog.txt`, então capturar stdout ajuda pouco. +- As execuções B e C aparecem como "fechou cedo" porque o SIGTERM do teste veio antes de 5 s. Isso é artefato do teste, não falha. +- `stat` da pasta de ROMs (mtime, tamanho, inode) idêntico antes e depois. Os symlinks ficaram num diretório temporário e foram apagados no fim. O próprio PCSX2 atualiza os arquivos dele a cada jogo (log, `playtime.dat`), como em qualquer uso normal. + +## Evidência complementar (emulador falso, `scripts/integration-launch.sh`) +- O argv chega byte a byte pelas duas estratégias, inclusive com `$()`, aspas, NFD, CJK e emoji. O LaunchServices não injeta argumentos extras. +- Com o app já aberto e `createsNewApplicationInstance = false`, o LaunchServices devolve a instância existente (mesmo pid) e **não entrega os novos argumentos**. Isso confirma a documentação da Apple e justifica a recusa feita pela validação. + +## Não coberto aqui +- ROM em pasta protegida pelo TCC (Documentos, Mesa, Downloads, volume removível): o prompt de privacidade e a quem ele é atribuído ficam para a Fase 2. +- PCSX2 nativo arm64 (não há build oficial no momento) e macOS 14 em runtime. +- Interação com `ConfirmShutdown=true` quando o usuário fecha o jogo pela interface do PCSX2 (validação manual no Gate G0). + +## Atualização (2026-10-28) +O usuário atualizou para o PCSX2 2.9.147. O texto de ajuda do binário novo traz o mesmo `Usage` e as mesmas flags usadas aqui (`-batch`, `--`, `-fullscreen`, `-nofullscreen`, `-bigpicture`, `-fastboot`, `-slowboot`), com o mesmo bundle ID e ainda x86_64. Não houve novo launch real nesta verificação. diff --git a/docs/spikes/0002-retroachievements-e-controle.md b/docs/spikes/0002-retroachievements-e-controle.md new file mode 100644 index 0000000..9b84cbe --- /dev/null +++ b/docs/spikes/0002-retroachievements-e-controle.md @@ -0,0 +1,42 @@ +# Spike 0002 — RetroAchievements e controle integrados ao PCSX2 + +**Data:** 2026-10-28 · **Status:** validado. A parte de RetroAchievements foi removida do app a pedido do usuário (D12); a de controle segue em leitura. + +## Perguntas +1. Dá para validar a configuração do RetroAchievements do emulador junto com o RetroX, inclusive por perfil? +2. Dá para ter um mini configurador de controle no RetroX que reflita no emulador? + +Tudo abaixo foi verificado só lendo arquivos (PCSX2 2.9.147, `PCSX2.ini` do usuário e texto do binário), sem alterar nada. + +## RetroAchievements +**O que o PCSX2 guarda:** a seção `[Achievements]` do `PCSX2.ini`, com `Enabled`, `ChallengeMode` (hardcore), `EncoreMode`, opções de notificação e, quando há login, `Username` e `Token`. Na máquina do usuário: `Enabled = false` e sem `Username` nem `Token`, ou seja, desligado e sem conta. + +**Implementado e depois removido (D12):** +- A descoberta do plugin lê `Enabled`, `ChallengeMode` e `Username`. **O token nunca é consultado nem exibido**; o teste `achievementsAndController` garante que ele não aparece na descoberta. +- Emuladores mostra "desligado", "ligado sem conta" ou "conectado como X (hardcore)". +- Cada perfil pode guardar o usuário do RetroAchievements. O editor de perfil compara com a conta conectada no PCSX2 e avisa se for outra. + +**Por perfil, de verdade, não foi feito:** trocar a conta do emulador ao trocar de perfil exigiria gravar `Username` e `Token` no `PCSX2.ini`. Isso significa guardar o token de cada jogador e alterar a configuração do emulador, o que a suposição S3 proíbe sem consentimento explícito. O PCSX2 tem um login global. + +**Possível depois (RF-18):** mostrar progresso e conquistas no RetroX pela Web API do RetroAchievements, com a chave de API que cada usuário gera no site. Não exige mexer no emulador. + +## Controle +**O que o PCSX2 guarda:** `[Pad1]` no `PCSX2.ini`, com `Type = DualShock2` e cada botão ligado a uma entrada SDL (`Cross = SDL-0/FaceSouth`, `L2 = SDL-0/+LeftTrigger`…). Ele também aceita perfis de entrada (`inputprofiles/*.ini`, referenciados por `InputProfileName`) e configurações por jogo passadas na linha de comando (`-gamecfg `, presente no texto de ajuda do binário). + +**Implementado:** +- Emuladores mostra o tipo de controle e o mapeamento da porta 1 com nomes amigáveis (por exemplo, "× → botão de baixo (A / ×)"). +- Ajustes > Controle: escolher o botão de confirmar do RetroX (baixo, ou direita no estilo Nintendo) e um teste ao vivo que mostra a ação de cada botão. Vale só para navegar no RetroX. + +**Caminho validado para "refletir no emulador", ainda não implementado:** +1. O RetroX grava um perfil de entrada próprio, `inputprofiles/RetroX.ini`, na pasta de dados do PCSX2. É um arquivo novo; nenhum arquivo existente é alterado. +2. Ao abrir um jogo, passa `-gamecfg ` com `InputProfileName = RetroX`, sem tocar no `PCSX2.ini`. + +Pendências antes de implementar: +- **Consentimento** para criar arquivos na pasta do PCSX2. +- Confirmar no código-fonte do PCSX2 em que seção do arquivo de jogo o `InputProfileName` é lido. +- Testar com um controle físico conectado. O teste do spike 0001 não teve controle. + +## Conclusão +- Validação do RetroAchievements: **possível e feita** (leitura). +- Conta por perfil: **não**, sem alterar a configuração do emulador. +- Configurador que reflete no emulador: **possível** pelo caminho `-gamecfg` + perfil de entrada próprio, aguardando consentimento e teste com controle. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..43b20cb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6391 @@ +{ + "name": "retrox-release", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "retrox-release", + "devDependencies": { + "@semantic-release/exec": "6.0.3", + "semantic-release": "24.2.9" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.8.tgz", + "integrity": "sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.5", + "@octokit/request": "^10.0.16", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.5.tgz", + "integrity": "sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.5.tgz", + "integrity": "sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.16", + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-29.0.1.tgz", + "integrity": "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-13.2.1.tgz", + "integrity": "sha512-Tj4PkZyIL6eBMYcG/76QGsedF0+dWVeLhYprTmuFVVxzDW7PQh23tM0TP0z+1MvSkxB29YFZwnUX+cXfTiSdyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^15.0.1" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-26.0.0.tgz", + "integrity": "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-15.0.2.tgz", + "integrity": "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^26.0.0" + } + }, + "node_modules/@octokit/plugin-retry": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.1.tgz", + "integrity": "sha512-VCVvZ/R1+u3WuiBWpNavZ0mY4aaJNAsENrpBP9aLSR2QyOpQgd7DhM5j4AW7z4MQpnJYgwBPf0XqPQoNBRdQwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^7.1.1", + "@octokit/types": "^17.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=7" + } + }, + "node_modules/@octokit/plugin-retry/node_modules/@octokit/openapi-types": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", + "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-retry/node_modules/@octokit/types": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", + "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^28.0.0" + } + }, + "node_modules/@octokit/plugin-throttling": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.5.tgz", + "integrity": "sha512-LIdrkrUv+DWbKeg/49rGuFJ3SU0d3hUS+B4MhNZLepBoNUFXms8Ic9edJjrlx+zycqJHjrMRudVpVb/bAXM2Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^17.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": "^7.0.0" + } + }, + "node_modules/@octokit/plugin-throttling/node_modules/@octokit/openapi-types": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", + "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-throttling/node_modules/@octokit/types": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", + "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^28.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.16.tgz", + "integrity": "sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.5", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "content-type": "^3.0.0", + "json-with-bigint": "^3.5.12", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.2.tgz", + "integrity": "sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^18.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-18.0.0.tgz", + "integrity": "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^29.0.1" + } + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@semantic-release/commit-analyzer": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.1.tgz", + "integrity": "sha512-wdnBPHKkr9HhNhXOhZD5a2LNl91+hs8CC2vsAVYxtZH3y0dV3wKn+uZSN61rdJQZ8EGxzWB3inWocBHV9+u/CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/error": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz", + "integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@semantic-release/exec": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@semantic-release/exec/-/exec-6.0.3.tgz", + "integrity": "sha512-bxAq8vLOw76aV89vxxICecEa8jfaWwYITw6X74zzlO0mc/Bgieqx9kBRz9z96pHectiTAtsCwsQcUyLYWnp3VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/error": "^3.0.0", + "aggregate-error": "^3.0.0", + "debug": "^4.0.0", + "execa": "^5.0.0", + "lodash": "^4.17.4", + "parse-json": "^5.0.0" + }, + "engines": { + "node": ">=14.17" + }, + "peerDependencies": { + "semantic-release": ">=18.0.0" + } + }, + "node_modules/@semantic-release/github": { + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-11.0.6.tgz", + "integrity": "sha512-ctDzdSMrT3H+pwKBPdyCPty6Y47X8dSrjd3aPZ5KKIKKWTwZBE9De8GtsH3TyAlw3Uyo2stegMx6rJMXKpJwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.0", + "@octokit/plugin-paginate-rest": "^13.0.0", + "@octokit/plugin-retry": "^8.0.0", + "@octokit/plugin-throttling": "^11.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "debug": "^4.3.4", + "dir-glob": "^3.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "issue-parser": "^7.0.0", + "lodash-es": "^4.17.21", + "mime": "^4.0.0", + "p-filter": "^4.0.0", + "tinyglobby": "^0.2.14", + "url-join": "^5.0.0" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=24.1.0" + } + }, + "node_modules/@semantic-release/github/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/github/node_modules/aggregate-error": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.1.0.tgz", + "integrity": "sha512-1m4/ydbOGBbHTtBYa4/ISQcqfPdZAxQLzj71wM71T8lP4ktez8lcsVolvscrujQW4CJr/LJSzoZ6mcfnqzYQ3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/github/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/github/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-12.0.2.tgz", + "integrity": "sha512-+M9/Lb35IgnlUO6OSJ40Ie+hUsZLuph2fqXC/qrKn0fMvUU/jiCjpoL6zEm69vzcmaZJ8yNKtMBEKHWN49WBbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "execa": "^9.0.0", + "fs-extra": "^11.0.0", + "lodash-es": "^4.17.21", + "nerf-dart": "^1.0.0", + "normalize-url": "^8.0.0", + "npm": "^10.9.3", + "rc": "^1.2.8", + "read-pkg": "^9.0.0", + "registry-auth-token": "^5.0.0", + "semver": "^7.1.2", + "tempy": "^3.0.0" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/npm/node_modules/aggregate-error": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.1.0.tgz", + "integrity": "sha512-1m4/ydbOGBbHTtBYa4/ISQcqfPdZAxQLzj71wM71T8lP4ktez8lcsVolvscrujQW4CJr/LJSzoZ6mcfnqzYQ3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@semantic-release/npm/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/release-notes-generator": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz", + "integrity": "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "read-package-up": "^11.0.0" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/argv-formatter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/argv-formatter/-/argv-formatter-1.0.0.tgz", + "integrity": "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cli-highlight/node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-highlight/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/content-type": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz", + "integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/conventional-changelog-angular": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", + "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-changelog-writer": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.4.0.tgz", + "integrity": "sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "conventional-commits-filter": "^5.0.0", + "handlebars": "^4.7.7", + "meow": "^13.0.0", + "semver": "^7.5.2" + }, + "bin": { + "conventional-changelog-writer": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-filter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", + "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", + "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crypto-random-string/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-ci": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.2.0.tgz", + "integrity": "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^8.0.0", + "java-properties": "^1.0.2" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/env-ci/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/env-ci/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/env-ci/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/env-ci/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-versions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-6.0.0.tgz", + "integrity": "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver-regex": "^4.0.5", + "super-regex": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/function-timeout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", + "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/git-log-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/git-log-parser/-/git-log-parser-1.2.1.tgz", + "integrity": "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "argv-formatter": "~1.0.0", + "spawn-error-forwarder": "~1.0.0", + "split2": "~1.0.0", + "stream-combiner2": "~1.1.1", + "through2": "~2.0.0", + "traverse": "0.6.8" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.8", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hook-std": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-4.0.0.tgz", + "integrity": "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hosted-git-info": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", + "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-from-esm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz", + "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" + }, + "engines": { + "node": ">=18.20" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/issue-parser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.2.tgz", + "integrity": "sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.capitalize": "^4.2.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.uniqby": "^4.7.0" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/java-properties": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", + "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.12.tgz", + "integrity": "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.capitalize": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", + "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-asynchronous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", + "integrity": "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "^1.5.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/marked-terminal": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.3.0.tgz", + "integrity": "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "ansi-regex": "^6.1.0", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "cli-table3": "^0.6.5", + "node-emoji": "^2.2.0", + "supports-hyperlinks": "^3.1.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "marked": ">=1 <16" + } + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.2.tgz", + "integrity": "sha512-ikRzZ2RwbUVDyL9SsU0f2Phcmav4yq5r8faH5ZZKHtOZrMBLnrQOrj0uvqZ5R1uQnj+RO7Wcklw/lAf81mP9LQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nerf-dart": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nerf-dart/-/nerf-dart-1.0.0.tgz", + "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-url": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm": { + "version": "10.9.9", + "resolved": "https://registry.npmjs.org/npm/-/npm-10.9.9.tgz", + "integrity": "sha512-1g+6jLQvaIuB4zwvHL7yrXuXcWZwDsCtBX8bbWDqbvJSSr9nPiDDWTHNgwXR27iIcTTW7v3A57hDW9RYv2W4Yg==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "cli-columns", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmhook", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "normalize-package-data", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which", + "write-file-atomic" + ], + "dev": true, + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^8.0.5", + "@npmcli/config": "^9.0.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/map-workspaces": "^4.0.2", + "@npmcli/package-json": "^6.2.0", + "@npmcli/promise-spawn": "^8.0.3", + "@npmcli/redact": "^3.2.2", + "@npmcli/run-script": "^9.1.0", + "@sigstore/tuf": "^3.1.1", + "abbrev": "^3.0.1", + "archy": "~1.0.0", + "cacache": "^19.0.1", + "chalk": "^5.6.2", + "ci-info": "^4.4.0", + "cli-columns": "^4.0.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^8.1.0", + "ini": "^5.0.0", + "init-package-json": "^7.0.2", + "is-cidr": "^5.1.1", + "json-parse-even-better-errors": "^4.0.0", + "libnpmaccess": "^9.0.0", + "libnpmdiff": "^7.0.5", + "libnpmexec": "^9.0.5", + "libnpmfund": "^6.0.5", + "libnpmhook": "^11.0.0", + "libnpmorg": "^7.0.0", + "libnpmpack": "^8.0.5", + "libnpmpublish": "^10.0.2", + "libnpmsearch": "^8.0.0", + "libnpmteam": "^7.0.0", + "libnpmversion": "^7.0.0", + "make-fetch-happen": "^14.0.3", + "minimatch": "^9.0.9", + "minipass": "^7.1.3", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^11.5.0", + "nopt": "^8.1.0", + "normalize-package-data": "^7.0.1", + "npm-audit-report": "^6.0.0", + "npm-install-checks": "^7.1.2", + "npm-package-arg": "^12.0.2", + "npm-pick-manifest": "^10.0.0", + "npm-profile": "^11.0.1", + "npm-registry-fetch": "^18.0.2", + "npm-user-validate": "^3.0.0", + "p-map": "^7.0.4", + "pacote": "^19.0.1", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "qrcode-terminal": "^0.12.0", + "read": "^4.1.0", + "semver": "^7.7.4", + "spdx-expression-parse": "^4.0.0", + "ssri": "^12.0.0", + "supports-color": "^9.4.0", + "tar": "^7.5.22", + "text-table": "~0.2.0", + "tiny-relative-date": "^1.3.0", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^6.0.2", + "which": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/@npmcli/agent": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/arborist": { + "version": "8.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/metavuln-calculator": "^8.0.0", + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.1", + "@npmcli/query": "^4.0.0", + "@npmcli/redact": "^3.0.0", + "@npmcli/run-script": "^9.0.1", + "bin-links": "^5.0.0", + "cacache": "^19.0.1", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^10.2.2", + "minimatch": "^9.0.4", + "nopt": "^8.0.0", + "npm-install-checks": "^7.1.0", + "npm-package-arg": "^12.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.1", + "pacote": "^19.0.0", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "proggy": "^3.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "promise-retry": "^2.0.1", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "ssri": "^12.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^3.0.1" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/config": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/package-json": "^6.0.1", + "ci-info": "^4.0.0", + "ini": "^5.0.0", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "walk-up-path": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/git": { + "version": "6.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^8.0.0", + "ini": "^5.0.0", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^10.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "4.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "glob": "^10.2.2", + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "8.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^19.0.0", + "json-parse-even-better-errors": "^4.0.0", + "pacote": "^20.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator/node_modules/pacote": { + "version": "20.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^7.5.10" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/package-json": { + "version": "6.2.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.5.3", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "8.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/query": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/redact": { + "version": "3.2.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "9.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "node-gyp": "^11.0.0", + "proc-log": "^5.0.0", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/@sigstore/bundle": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/core": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.4.3", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/sign": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "make-fetch-happen": "^14.0.2", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/tuf": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.1", + "tuf-js": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/verify": { + "version": "2.1.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/abbrev": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ansi-styles": { + "version": "6.2.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/aproba": { + "version": "2.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/bin-links": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^7.0.0", + "npm-normalize-package-bin": "^4.0.0", + "proc-log": "^5.0.0", + "read-cmd-shim": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/binary-extensions": { + "version": "2.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/brace-expansion": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/npm/node_modules/cacache": { + "version": "19.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/npm/node_modules/chownr": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/ci-info": { + "version": "4.4.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/cidr-regex": { + "version": "4.1.3", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "ip-regex": "^5.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/cli-columns": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/cmd-shim": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/npm/node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/diff": { + "version": "5.2.2", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/npm/node_modules/eastasianwidth": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/encoding": { + "version": "0.1.13", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/err-code": { + "version": "2.0.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.3", + "dev": true, + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/npm/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/foreground-child": { + "version": "3.3.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/glob": { + "version": "10.5.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/hosted-git-info": { + "version": "8.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/npm/node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/iconv-lite": { + "version": "0.6.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm/node_modules/ignore-walk": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/npm/node_modules/ini": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/init-package-json": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^6.0.0", + "npm-package-arg": "^12.0.0", + "promzard": "^2.0.0", + "read": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/ip-address": { + "version": "10.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/npm/node_modules/ip-regex": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/is-cidr": { + "version": "5.1.1", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^4.1.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/jackspeak": { + "version": "3.4.3", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/libnpmaccess": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^12.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff": { + "version": "7.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^8.0.5", + "@npmcli/installed-package-contents": "^3.0.0", + "binary-extensions": "^2.3.0", + "diff": "^5.1.0", + "minimatch": "^9.0.4", + "npm-package-arg": "^12.0.0", + "pacote": "^19.0.0", + "tar": "^7.5.11" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmexec": { + "version": "9.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^8.0.5", + "@npmcli/run-script": "^9.0.1", + "ci-info": "^4.0.0", + "npm-package-arg": "^12.0.0", + "pacote": "^19.0.0", + "proc-log": "^5.0.0", + "read": "^4.0.0", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "walk-up-path": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmfund": { + "version": "6.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^8.0.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmhook": { + "version": "11.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmorg": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmpack": { + "version": "8.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^8.0.5", + "@npmcli/run-script": "^9.0.1", + "npm-package-arg": "^12.0.0", + "pacote": "^19.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmpublish": { + "version": "10.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "ci-info": "^4.0.0", + "normalize-package-data": "^7.0.0", + "npm-package-arg": "^12.0.0", + "npm-registry-fetch": "^18.0.1", + "proc-log": "^5.0.0", + "semver": "^7.3.7", + "sigstore": "^3.0.0", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmsearch": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmteam": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmversion": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.1", + "@npmcli/run-script": "^9.0.1", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/make-fetch-happen": { + "version": "14.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/minimatch": { + "version": "9.0.9", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-fetch": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/minipass-sized": { + "version": "1.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/minizlib": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/mute-stream": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/negotiator": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm/node_modules/node-gyp": { + "version": "11.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "tinyglobby": "^0.2.12", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/nopt": { + "version": "8.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/normalize-package-data": { + "version": "7.0.1", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^8.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-audit-report": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-bundled": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-install-checks": { + "version": "7.1.2", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-package-arg": { + "version": "12.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-packlist": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^7.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest": { + "version": "10.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^7.1.0", + "npm-normalize-package-bin": "^4.0.0", + "npm-package-arg": "^12.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-profile": { + "version": "11.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch": { + "version": "18.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^3.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^14.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-user-validate": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/p-map": { + "version": "7.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/package-json-from-dist": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/npm/node_modules/pacote": { + "version": "19.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^7.5.10" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/parse-conflict-json": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/path-scurry": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/npm/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/proc-log": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/proggy": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-retry": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/promzard": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/npm/node_modules/read": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^2.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/read-cmd-shim": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/read-package-json-fast": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/retry": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/npm/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/sigstore": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "@sigstore/sign": "^3.1.0", + "@sigstore/tuf": "^3.1.0", + "@sigstore/verify": "^2.1.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks": { + "version": "2.8.7", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/spdx-correct": { + "version": "3.2.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.23", + "dev": true, + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/npm/node_modules/ssri": { + "version": "12.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/supports-color": { + "version": "9.4.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/npm/node_modules/tar": { + "version": "7.5.22", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tiny-relative-date": { + "version": "1.3.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/tuf-js": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "3.0.1", + "debug": "^4.4.1", + "make-fetch-happen": "^14.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/tuf-js/node_modules/@tufjs/models": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/unique-filename": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/unique-slug": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/validate-npm-package-license": { + "version": "3.0.4", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-name": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/walk-up-path": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/which": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/which/node_modules/isexe": { + "version": "3.1.5", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/wrap-ansi": { + "version": "8.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/write-file-atomic": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/yallist": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-each-series": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", + "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-filter": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-4.1.0.tgz", + "integrity": "sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-map": "^7.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-map": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.1.1.tgz", + "integrity": "sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-reduce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", + "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", + "integrity": "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^2.0.0", + "load-json-file": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", + "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/read-package-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/registry-auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^3.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semantic-release": { + "version": "24.2.9", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-24.2.9.tgz", + "integrity": "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/commit-analyzer": "^13.0.0-beta.1", + "@semantic-release/error": "^4.0.0", + "@semantic-release/github": "^11.0.0", + "@semantic-release/npm": "^12.0.2", + "@semantic-release/release-notes-generator": "^14.0.0-beta.1", + "aggregate-error": "^5.0.0", + "cosmiconfig": "^9.0.0", + "debug": "^4.0.0", + "env-ci": "^11.0.0", + "execa": "^9.0.0", + "figures": "^6.0.0", + "find-versions": "^6.0.0", + "get-stream": "^6.0.0", + "git-log-parser": "^1.2.0", + "hook-std": "^4.0.0", + "hosted-git-info": "^8.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "marked": "^15.0.0", + "marked-terminal": "^7.3.0", + "micromatch": "^4.0.2", + "p-each-series": "^3.0.0", + "p-reduce": "^3.0.0", + "read-package-up": "^11.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.3.2", + "semver-diff": "^5.0.0", + "signale": "^1.2.1", + "yargs": "^17.5.1" + }, + "bin": { + "semantic-release": "bin/semantic-release.js" + }, + "engines": { + "node": ">=20.8.1" + } + }, + "node_modules/semantic-release/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/semantic-release/node_modules/aggregate-error": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.1.0.tgz", + "integrity": "sha512-1m4/ydbOGBbHTtBYa4/ISQcqfPdZAxQLzj71wM71T8lP4ktez8lcsVolvscrujQW4CJr/LJSzoZ6mcfnqzYQ3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/semantic-release/node_modules/execa/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/semantic-release/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/semantic-release/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-5.0.0.tgz", + "integrity": "sha512-0HbGtOm+S7T6NGQ/pxJSJipJvc4DK3FcRVMRkhsIwJDJ4Jcz5DQC1cPPzB5GhzyHjwttW878HaWQq46CkL3cqg==", + "deprecated": "Deprecated as the semver package now supports this built-in.", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver-regex": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", + "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/signale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz", + "integrity": "sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.3.2", + "figures": "^2.0.0", + "pkg-conf": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/signale/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/signale/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/signale/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/signale/node_modules/figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spawn-error-forwarder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz", + "integrity": "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/split2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", + "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", + "dev": true, + "license": "ISC", + "dependencies": { + "through2": "~2.0.0" + } + }, + "node_modules/stream-combiner2": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", + "integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer2": "~0.1.0", + "readable-stream": "^2.0.2" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/super-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", + "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-timeout": "^1.0.1", + "make-asynchronous": "^1.0.1", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/tempy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.2.0.tgz", + "integrity": "sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^3.0.0", + "temp-dir": "^3.0.0", + "type-fest": "^2.12.2", + "unique-string": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/traverse": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", + "integrity": "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unique-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", + "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/url-join": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.2.0.tgz", + "integrity": "sha512-/tMVQdp/2eE90fGb6npbd2nLHH8jamzLLeZhLEKTz4Rc38wO5AeEuIhoWroWWfQJS50ZzL4Xod9JER/pJWTTEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1ff28ff --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "retrox-release", + "private": true, + "scripts": { + "release": "semantic-release" + }, + "devDependencies": { + "@semantic-release/exec": "6.0.3", + "semantic-release": "24.2.9" + } +} diff --git a/scripts/brand-kit.swift b/scripts/brand-kit.swift new file mode 100644 index 0000000..627cc2f --- /dev/null +++ b/scripts/brand-kit.swift @@ -0,0 +1,405 @@ +// Gera o kit da marca a partir de docs/design/rayo/rayo.png. Uso: swift scripts/brand-kit.swift +// +// Escreve docs/design/logo/ (ícone, tile, assinaturas, X e prancha), Support/RetroX.icns e o tile usado pelo app +// (Sources/RetroXApp/Resources/RayoMark.png). As regras estão em docs/design/logo/README.md; o alinhamento é o +// mesmo de BrandLockup no app. +import AppKit +import CoreText + +let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) +let source = root.appendingPathComponent("docs/design/rayo/rayo.png") +let logo = root.appendingPathComponent("docs/design/logo") + +func hex(_ value: UInt32) -> NSColor { + NSColor(srgbRed: CGFloat((value >> 16) & 0xFF) / 255, green: CGFloat((value >> 8) & 0xFF) / 255, + blue: CGFloat(value & 0xFF) / 255, alpha: 1) +} + +func hexString(_ color: NSColor) -> String { + let c = color.usingColorSpace(.sRGB)! + return String(format: "#%02X%02X%02X", Int((c.redComponent * 255).rounded()), Int((c.greenComponent * 255).rounded()), + Int((c.blueComponent * 255).rounded())) +} + +enum Palette { + static let rose = hex(0xFEB5B8) // cabelo da Rayo: início do X sobre o escuro + static let sky = hex(0x7BADFD) // fundo do tile: fim do X sobre o escuro + static let raspberry = hex(0xD63A62) // o X sobre o claro: sólido, sem gradiente + static let ink = hex(0x1E1B18), paper = hex(0xF3EEE7) + static let night = hex(0x1B1916), day = hex(0xF4F0EA) + static let board = hex(0x262320), muted = hex(0xADA59B) +} + +/// Cores de uma assinatura: o "Retro" e as duas pontas do gradiente do X (iguais = X sólido). +struct Tone { + let text, from, to: NSColor + static let onDark = Tone(text: Palette.paper, from: Palette.rose, to: Palette.sky) + static let onLight = Tone(text: Palette.ink, from: Palette.raspberry, to: Palette.raspberry) +} + +// MARK: Gradiente em OKLCH + +/// Mistura em OKLCH (espaço perceptual): o meio do gradiente fica vivo, sem o cinza da mistura em RGB. +enum OKLCH { + private static func linear(_ c: CGFloat) -> CGFloat { c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) } + private static func gamma(_ c: CGFloat) -> CGFloat { + let c = min(max(c, 0), 1) + return c <= 0.0031308 ? 12.92 * c : 1.055 * pow(c, 1 / 2.4) - 0.055 + } + + static func lch(_ color: NSColor) -> (l: CGFloat, c: CGFloat, h: CGFloat) { + let s = color.usingColorSpace(.sRGB)! + let r = linear(s.redComponent), g = linear(s.greenComponent), b = linear(s.blueComponent) + let l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + let m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + let q = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + let L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * q + let A = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * q + let B = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * q + return (L, hypot(A, B), atan2(B, A)) + } + + static func color(l: CGFloat, c: CGFloat, h: CGFloat) -> NSColor { + let A = c * cos(h), B = c * sin(h) + let l3 = pow(l + 0.3963377774 * A + 0.2158037573 * B, 3) + let m3 = pow(l - 0.1055613458 * A - 0.0638541728 * B, 3) + let q3 = pow(l - 0.0894841775 * A - 1.2914855480 * B, 3) + let r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * q3 + let g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * q3 + let b = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * q3 + return NSColor(srgbRed: gamma(r), green: gamma(g), blue: gamma(b), alpha: 1) + } + + /// `count` paradas de `from` a `to`, pelo caminho mais curto no círculo de matiz. + static func stops(from: NSColor, to: NSColor, count: Int = 9) -> [NSColor] { + let a = lch(from), b = lch(to) + var dh = b.h - a.h + if dh > .pi { dh -= 2 * .pi } else if dh < -.pi { dh += 2 * .pi } + return (0.. 0.6 else { continue } + minX = min(minX, x); maxX = max(maxX, x); minY = min(minY, y); maxY = max(maxY, y) + } + } + let inset = 6 + return NSRect(x: minX + inset, y: sourceRep.pixelsHigh - maxY + inset, + width: maxX - minX - 2 * inset, height: maxY - minY - 2 * inset) +}() + +/// Raio do tile em fração do lado: o mesmo da grade de ícone do macOS (185/824). +let tileRadius: CGFloat = 185.0 / 824.0 + +func drawTile(in rect: NSRect) { + NSGraphicsContext.saveGraphicsState() + NSBezierPath(roundedRect: rect, xRadius: rect.width * tileRadius, yRadius: rect.width * tileRadius).addClip() + NSGraphicsContext.current?.imageInterpolation = .high + sourceImage.draw(in: rect, from: tileCrop, operation: .copy, fraction: 1) + NSGraphicsContext.restoreGraphicsState() +} + +// MARK: O X + +/// O X da marca: a letra da fonte com gradiente, do canto de cima à esquerda ao de baixo à direita. A cor inicial +/// segura até 30% e então caminha até a final, misturada em OKLCH. Sobre o claro as duas cores são iguais: sólido. +enum BrandX { + static func draw(glyph: CGPath, size: CGFloat, tone: Tone, in ctx: CGContext) { + let b = glyph.boundingBoxOfPath + let stops = OKLCH.stops(from: tone.from, to: tone.to) + let colors = [stops[0]] + stops + let locations: [CGFloat] = [0] + (0.. NSFont { + let base = NSFont.systemFont(ofSize: size, weight: .heavy) + return NSFont(descriptor: base.fontDescriptor.withDesign(.rounded)!, size: size)! +} + +struct Wordmark { + /// Aperto extra entre "o" e "X", em fração do corpo (o app usa o mesmo valor). + static let oxKern: CGFloat = -0.045 + let size: CGFloat + let tone: Tone + let line: CTLine + let font: NSFont + /// Largura da tinta, do primeiro ao último traço (sem os recuos laterais das letras). + let inkWidth: CGFloat + let leftBearing: CGFloat + /// Contorno do glifo "X", na posição dele dentro da linha. + let xGlyph: CGPath + + init(size: CGFloat, tone: Tone) { + self.size = size + self.tone = tone + font = brandFont(size) + let tracking = -0.015 * size + let string = NSMutableAttributedString(string: "RetroX", attributes: [ + .font: font, .foregroundColor: tone.text, .kern: tracking, + ]) + // O X é desenhado à parte (BrandX); na linha ele só ocupa o lugar. + string.addAttribute(.foregroundColor, value: NSColor.clear, range: NSRange(location: 5, length: 1)) + // O X tem recuo lateral largo: sem isso, o vão "o→X" fica o dobro dos outros. + string.addAttribute(.kern, value: tracking + Wordmark.oxKern * size, range: NSRange(location: 4, length: 1)) + string.addAttribute(.kern, value: 0, range: NSRange(location: 5, length: 1)) + line = CTLineCreateWithAttributedString(string) + let ink = CTLineGetImageBounds(line, nil) + inkWidth = ink.width + leftBearing = ink.minX + + var glyphPath: CGPath? + for run in CTLineGetGlyphRuns(line) as! [CTRun] { + let count = CTRunGetGlyphCount(run) + var glyphs = [CGGlyph](repeating: 0, count: count), positions = [CGPoint](repeating: .zero, count: count) + var indices = [CFIndex](repeating: 0, count: count) + CTRunGetGlyphs(run, CFRange(), &glyphs); CTRunGetPositions(run, CFRange(), &positions) + CTRunGetStringIndices(run, CFRange(), &indices) + let runFont = (CTRunGetAttributes(run) as NSDictionary)[NSAttributedString.Key.font] as! NSFont + for i in 0.. NSBitmapImageRep { + let word = Wordmark(size: fontSize, tone: tone) + let cap = word.capHeight, tile = 2 * cap, gap = 0.75 * cap, pad = cap + return render(width: pad + tile + gap + word.inkWidth + pad, height: pad + tile + pad) { + drawTile(in: NSRect(x: pad, y: pad, width: tile, height: tile)) + word.draw(x: pad + tile + gap, baseline: pad + (tile - cap) / 2) + } +} + +/// Empilhada: tile = 3 × altura das maiúsculas, vão até o topo das maiúsculas = 1 × altura das maiúsculas, +/// os dois centrados pela tinta. +func stacked(fontSize: CGFloat, tone: Tone) -> NSBitmapImageRep { + let word = Wordmark(size: fontSize, tone: tone) + let cap = word.capHeight, tile = 3 * cap, gap = cap, pad = cap + let width = pad + max(tile, word.inkWidth) + pad + return render(width: width, height: pad + cap + gap + tile + pad) { + drawTile(in: NSRect(x: (width - tile) / 2, y: pad + cap + gap, width: tile, height: tile)) + word.draw(x: (width - word.inkWidth) / 2, baseline: pad) + } +} + +func wordmark(fontSize: CGFloat, tone: Tone) -> NSBitmapImageRep { + let word = Wordmark(size: fontSize, tone: tone) + let pad = word.capHeight * 0.5 + return render(width: word.inkWidth + 2 * pad, height: word.capHeight + 2 * pad) { + word.draw(x: pad, baseline: pad) + } +} + +/// Só o X, centrado num quadrado transparente. +func xMark(side: CGFloat, tone: Tone) -> NSBitmapImageRep { + let size = side * 0.62 / brandFont(100).capHeight * 100 + let font = brandFont(size) + var glyph = CGGlyph() + var character = UniChar(("X" as Character).asciiValue!) + CTFontGetGlyphsForCharacters(font as CTFont, &character, &glyph, 1) + let path = CTFontCreatePathForGlyph(font as CTFont, glyph, nil)! + let b = path.boundingBoxOfPath + var center = CGAffineTransform(translationX: (side - b.width) / 2 - b.minX, y: (side - b.height) / 2 - b.minY) + return render(width: side, height: side) { + BrandX.draw(glyph: path.copy(using: ¢er)!, size: size, tone: tone, in: NSGraphicsContext.current!.cgContext) + } +} + +// MARK: Saída + +func render(width: CGFloat, height: CGFloat, background: NSColor? = nil, draw: () -> Void) -> NSBitmapImageRep { + let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(width.rounded(.up)), pixelsHigh: Int(height.rounded(.up)), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)! + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + if let background { + background.setFill() + NSRect(x: 0, y: 0, width: width, height: height).fill() + } + draw() + NSGraphicsContext.restoreGraphicsState() + return rep +} + +/// Ícone na grade do macOS: corpo de 824/1024, cantos transparentes. +func icon(side: Int) -> NSBitmapImageRep { + let k = CGFloat(side) / 1024 + return render(width: CGFloat(side), height: CGFloat(side)) { + drawTile(in: NSRect(x: 100 * k, y: 100 * k, width: 824 * k, height: 824 * k)) + } +} + +func tileOnly(side: Int) -> NSBitmapImageRep { + render(width: CGFloat(side), height: CGFloat(side)) { + drawTile(in: NSRect(x: 0, y: 0, width: side, height: side)) + } +} + +func write(_ rep: NSBitmapImageRep, _ url: URL) { + try! rep.representation(using: .png, properties: [:])!.write(to: url) + print(url.path.replacingOccurrences(of: root.path + "/", with: "")) +} + +func image(_ rep: NSBitmapImageRep) -> NSImage { + let image = NSImage(size: NSSize(width: rep.pixelsWide, height: rep.pixelsHigh)) + image.addRepresentation(rep) + return image +} + +try? FileManager.default.createDirectory(at: logo, withIntermediateDirectories: true) + +write(icon(side: 1024), logo.appendingPathComponent("retrox-icon-1024.png")) +write(tileOnly(side: 512), logo.appendingPathComponent("retrox-tile-512.png")) +write(tileOnly(side: 384), root.appendingPathComponent("Sources/RetroXApp/Resources/RayoMark.png")) + +let lockupDark = horizontal(fontSize: 160, tone: .onDark) +let lockupLight = horizontal(fontSize: 160, tone: .onLight) +let stackDark = stacked(fontSize: 120, tone: .onDark) +let stackLight = stacked(fontSize: 120, tone: .onLight) +let xDark = xMark(side: 512, tone: .onDark) +let xLight = xMark(side: 512, tone: .onLight) +write(lockupDark, logo.appendingPathComponent("retrox-lockup-on-dark.png")) +write(lockupLight, logo.appendingPathComponent("retrox-lockup-on-light.png")) +write(stackDark, logo.appendingPathComponent("retrox-stacked-on-dark.png")) +write(stackLight, logo.appendingPathComponent("retrox-stacked-on-light.png")) +write(wordmark(fontSize: 160, tone: .onDark), logo.appendingPathComponent("retrox-wordmark-on-dark.png")) +write(wordmark(fontSize: 160, tone: .onLight), logo.appendingPathComponent("retrox-wordmark-on-light.png")) +write(xDark, logo.appendingPathComponent("retrox-x-on-dark.png")) +write(xLight, logo.appendingPathComponent("retrox-x-on-light.png")) + +// Iconset → .icns +let iconset = FileManager.default.temporaryDirectory.appendingPathComponent("RetroX-\(UUID().uuidString).iconset") +try! FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true) +for side in [16, 32, 128, 256, 512] { + try! icon(side: side).representation(using: .png, properties: [:])! + .write(to: iconset.appendingPathComponent("icon_\(side)x\(side).png")) + try! icon(side: side * 2).representation(using: .png, properties: [:])! + .write(to: iconset.appendingPathComponent("icon_\(side)x\(side)@2x.png")) +} +let iconutil = Process() +iconutil.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil") +iconutil.arguments = ["-c", "icns", iconset.path, "-o", root.appendingPathComponent("Support/RetroX.icns").path] +try! iconutil.run() +iconutil.waitUntilExit() +try? FileManager.default.removeItem(at: iconset) +print("Support/RetroX.icns") + +// MARK: Prancha + +/// Assinaturas sobre o escuro e o claro; embaixo, o X, as cores e o ícone em tamanhos reais. +let boardWidth: CGFloat = 2400, band: CGFloat = 620, base: CGFloat = 680 +let boardHeight = band * 2 + base +let board = render(width: boardWidth, height: boardHeight, background: Palette.board) { + func place(_ rep: NSBitmapImageRep, centerX: CGFloat, centerY: CGFloat, scale: CGFloat = 1) { + let w = CGFloat(rep.pixelsWide) * scale, h = CGFloat(rep.pixelsHigh) * scale + image(rep).draw(in: NSRect(x: (centerX - w / 2).rounded(), y: (centerY - h / 2).rounded(), width: w, height: h)) + } + func caption(_ text: String, x: CGFloat, y: CGFloat, color: NSColor = Palette.muted, size: CGFloat = 22, + weight: NSFont.Weight = .semibold, tracking: CGFloat = 2) { + NSAttributedString(string: text, attributes: [ + .font: NSFont.systemFont(ofSize: size, weight: weight), .foregroundColor: color, .kern: tracking, + ]).draw(at: NSPoint(x: x, y: y)) + } + + // Assinaturas + let darkTop = boardHeight, lightTop = boardHeight - band + Palette.night.setFill(); NSRect(x: 0, y: darkTop - band, width: boardWidth, height: band).fill() + Palette.day.setFill(); NSRect(x: 0, y: lightTop - band, width: boardWidth, height: band).fill() + caption("SOBRE O ESCURO", x: 64, y: darkTop - 72, color: hex(0x7A7268)) + caption("SOBRE O CLARO", x: 64, y: lightTop - 72, color: hex(0x8C8378)) + place(lockupDark, centerX: 760, centerY: darkTop - band / 2, scale: 0.9) + place(stackDark, centerX: 1880, centerY: darkTop - band / 2, scale: 0.85) + place(lockupLight, centerX: 760, centerY: lightTop - band / 2, scale: 0.9) + place(stackLight, centerX: 1880, centerY: lightTop - band / 2, scale: 0.85) + + // O X + let titleY = base - 92 + caption("O X", x: 64, y: titleY) + let square: CGFloat = 270, squareY: CGFloat = 150 + Palette.night.setFill(); NSBezierPath(roundedRect: NSRect(x: 64, y: squareY, width: square, height: square), xRadius: 24, yRadius: 24).fill() + Palette.day.setFill(); NSBezierPath(roundedRect: NSRect(x: 64 + square + 24, y: squareY, width: square, height: square), xRadius: 24, yRadius: 24).fill() + place(xDark, centerX: 64 + square / 2, centerY: squareY + square / 2, scale: square / 512) + place(xLight, centerX: 64 + square * 1.5 + 24, centerY: squareY + square / 2, scale: square / 512) + caption("Escuro: gradiente em OKLCH · Claro: sólido", x: 64, y: squareY - 56, color: Palette.paper, size: 20, weight: .regular, tracking: 0) + + // Cores + let colorsX: CGFloat = 740 + caption("CORES", x: colorsX, y: titleY) + func gradientBar(_ tone: Tone, y: CGFloat, label: String) { + let rect = NSRect(x: colorsX, y: y, width: 620, height: 64) + NSGraphicsContext.saveGraphicsState() + NSBezierPath(roundedRect: rect, xRadius: 32, yRadius: 32).addClip() + NSGradient(colors: OKLCH.stops(from: tone.from, to: tone.to))!.draw(in: rect, angle: 0) + NSGraphicsContext.restoreGraphicsState() + let codes = hexString(tone.from) == hexString(tone.to) ? hexString(tone.from) : "\(hexString(tone.from)) → \(hexString(tone.to))" + caption(codes, x: colorsX + 644, y: y + 30, color: Palette.paper, size: 20, weight: .semibold, tracking: 0) + caption(label, x: colorsX + 644, y: y + 4, size: 18, weight: .regular, tracking: 0) + } + gradientBar(.onDark, y: 430, label: "X sobre o escuro") + gradientBar(.onLight, y: 330, label: "X sobre o claro (sólido)") + let chips: [(NSColor, String)] = [(Palette.ink, "Tinta"), (Palette.paper, "Papel"), (Palette.night, "Noite"), (Palette.day, "Dia")] + for (i, chip) in chips.enumerated() { + let x = colorsX + CGFloat(i) * 235 + chip.0.setFill() + NSBezierPath(roundedRect: NSRect(x: x, y: 170, width: 72, height: 72), xRadius: 16, yRadius: 16).fill() + hex(0x3B3632).setStroke() + let outline = NSBezierPath(roundedRect: NSRect(x: x + 0.5, y: 170.5, width: 71, height: 71), xRadius: 16, yRadius: 16) + outline.lineWidth = 1; outline.stroke() + caption(chip.1, x: x + 88, y: 212, color: Palette.paper, size: 20, weight: .semibold, tracking: 0) + caption(hexString(chip.0), x: x + 88, y: 184, size: 18, weight: .regular, tracking: 0) + } + + // Ícone + let iconX: CGFloat = 1700 + caption("ÍCONE", x: iconX, y: titleY) + var x = iconX + for side in [256, 128, 64, 32, 16] { + place(icon(side: side), centerX: x + CGFloat(side) / 2, centerY: squareY + square / 2) + x += CGFloat(side) + 36 + } +} +write(board, logo.appendingPathComponent("retrox-kit.png")) diff --git a/scripts/bundle-app.sh b/scripts/bundle-app.sh new file mode 100755 index 0000000..f164f2b --- /dev/null +++ b/scripts/bundle-app.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Monta .build/RetroX.app a partir do executável do SwiftPM e assina ad hoc (ADR-0010). +# Uso: scripts/bundle-app.sh [debug|release] +set -euo pipefail +cd "$(dirname "$0")/.." + +configuration="${1:-debug}" +case "$configuration" in + debug|release) ;; + *) echo "uso: $0 [debug|release]" >&2; exit 64 ;; +esac + +swift build -c "$configuration" --product RetroX +bin_dir="$(swift build -c "$configuration" --show-bin-path)" + +app=".build/RetroX.app" +rm -rf "$app" +mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" +cp "$bin_dir/RetroX" "$app/Contents/MacOS/RetroX" +cp Support/Info.plist "$app/Contents/Info.plist" +cp Support/RetroX.icns "$app/Contents/Resources/RetroX.icns" +# Recursos do SwiftPM (marca da Rayo); o Bundle.module procura em Contents/Resources. +cp -R "$bin_dir/RetroX_RetroXApp.bundle" "$app/Contents/Resources/" +codesign --force --sign - "$app" + +echo "$app" diff --git a/scripts/check-toolchain.sh b/scripts/check-toolchain.sh new file mode 100755 index 0000000..25bd790 --- /dev/null +++ b/scripts/check-toolchain.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Mostra o que o toolchain atual consegue fazer pelo RetroX. Só lê; não altera nada. +set -uo pipefail + +ok() { printf ' [ok] %s\n' "$1"; } +warn() { printf ' [falta] %s\n' "$1"; } + +echo "Sistema: macOS $(sw_vers -productVersion) ($(uname -m))" +echo "Toolchain: $(xcode-select -p 2>/dev/null || echo 'nenhum')" +echo "Swift: $(swift --version 2>/dev/null | head -1)" +echo + +if xcodebuild -version >/dev/null 2>&1; then + ok "Xcode: $(xcodebuild -version | head -1)" +else + warn "Xcode: só Command Line Tools. Sem xcodebuild, XCUITest e Instruments." +fi + +if printf 'import SwiftData\n@Model final class Probe { var x = 0; init() {} }\n' \ + | swiftc -typecheck -target "$(uname -m)-apple-macos14" - >/dev/null 2>&1; then + ok "SwiftData (@Model): compila. A Fase 2 pode começar." +else + warn "SwiftData (@Model): não compila neste toolchain (instale o Xcode)." +fi + +if [ -e /Library/Apple/usr/libexec/oah/libRosettaRuntime ]; then + ok "Rosetta: instalado (necessário para emuladores só x86_64, como o PCSX2 2.9)." +else + warn "Rosetta: não instalado. Emuladores só x86_64 não abrem em Apple Silicon." +fi diff --git a/scripts/integration-launch.sh b/scripts/integration-launch.sh new file mode 100755 index 0000000..6d326dd --- /dev/null +++ b/scripts/integration-launch.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Testes de integração do launch com um emulador falso (rx-fake-emulator), aberto de verdade +# pelo LaunchServices e por exec direto. Não toca em emuladores nem em ROMs reais. +set -euo pipefail +cd "$(dirname "$0")/.." + +filter='LaunchIntegrationTests' +if [[ "${1:-}" == --process-only ]]; then + filter='ProcessLaunchIntegrationTests' + shift +fi + +swift build --product rx-fake-emulator +bin_dir="$(swift build --show-bin-path)" +RX_FAKE_EMULATOR_BIN="$bin_dir/rx-fake-emulator" exec scripts/test.sh --filter "$filter" "$@" diff --git a/scripts/package-app.sh b/scripts/package-app.sh new file mode 100755 index 0000000..3a1a6b0 --- /dev/null +++ b/scripts/package-app.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Cria um ZIP do app, assinado ad hoc, para a arquitetura do host. +set -euo pipefail +cd "$(dirname "$0")/.." + +version="${1:?uso: scripts/package-app.sh VERSION}" +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "versão inválida: $version" >&2 + exit 64 +fi + +scripts/bundle-app.sh release +app=".build/RetroX.app" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${version%%[-+]*}" "$app/Contents/Info.plist" +codesign --force --sign - "$app" +codesign --verify --deep --strict "$app" + +mkdir -p dist +archive="RetroX-${version}-macos-$(uname -m).zip" +rm -f "dist/$archive" +ditto -c -k --sequesterRsrc --keepParent "$app" "dist/$archive" +(cd dist && shasum -a 256 "$archive" > SHA256SUMS.txt) +printf 'Pacote: dist/%s\n' "$archive" diff --git a/scripts/test-installed-emulator.sh b/scripts/test-installed-emulator.sh new file mode 100755 index 0000000..963032d --- /dev/null +++ b/scripts/test-installed-emulator.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Inspeciona uma instalação fornecida explicitamente, sem executar o emulador. +set -euo pipefail +cd "$(dirname "$0")/.." +if [[ $# -ne 2 || ! -d "$2" ]]; then + echo "uso: $0 pcsx2|azahar|ryujinx /caminho/Emulador.app" >&2 + exit 64 +fi +case "$1" in + pcsx2) export RX_PCSX2_APP="$2" ;; + azahar) export RX_AZAHAR_APP="$2" ;; + ryujinx) export RX_RYUJINX_APP="$2" ;; + *) echo "Emulador desconhecido: $1" >&2; exit 64 ;; +esac +exec scripts/test.sh --filter InstalledEmulatorTests diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..573a8b3 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Roda `swift test` repassando os argumentos recebidos. +# +# Nas Command Line Tools (Swift 6.4, backend swiftbuild), a descoberta do plugin de macros do +# Swift Testing é intermitente: às vezes o compilador não recebe o TestingMacros e o build falha +# com "plugin for module 'TestingMacros' not found". Passar o caminho do plugin explicitamente +# resolve. Com o Xcode ativo, nada é acrescentado. +set -euo pipefail +cd "$(dirname "$0")/.." + +extra=() +developer_dir="$(xcode-select -p 2>/dev/null || true)" +plugin_dir="$developer_dir/usr/lib/swift/host/plugins/testing" +if [[ "$developer_dir" == *CommandLineTools* && -d "$plugin_dir" ]]; then + extra=(-Xswiftc -plugin-path -Xswiftc "$plugin_dir") +fi + +# A forma `${extra[@]+...}` evita o erro de `set -u` com array vazio no bash 3.2 (o do macOS e dos runners). +exec swift test ${extra[@]+"${extra[@]}"} "$@" diff --git a/scripts/verify-package.sh b/scripts/verify-package.sh new file mode 100755 index 0000000..49162a2 --- /dev/null +++ b/scripts/verify-package.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Verifica o artefato distribuído, após extrair o ZIP tal como quem o baixa. +set -euo pipefail +cd "$(dirname "$0")/.." + +version="${1:?uso: scripts/verify-package.sh VERSION}" +require_file() { + if [[ ! -s "$1" ]]; then echo "Arquivo ausente ou vazio no pacote: $1" >&2; exit 1; fi +} +arch="$(uname -m)" +archive="dist/RetroX-${version}-macos-${arch}.zip" +require_file "$archive" +(cd dist && shasum -a 256 -c SHA256SUMS.txt) + +temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/retrox-package-test.XXXXXX")" +trap 'rm -rf "$temp_dir"' EXIT +ditto -x -k "$archive" "$temp_dir" +app="$temp_dir/RetroX.app" +require_file "$app/Contents/MacOS/RetroX" +if [[ ! -x "$app/Contents/MacOS/RetroX" ]]; then echo "RetroX sem permissão de execução" >&2; exit 1; fi +require_file "$app/Contents/Resources/RetroX.icns" +resources="$app/Contents/Resources/RetroX_RetroXApp.bundle" +# SwiftPM usa bundle plano com o backend clássico e Contents/Resources com swiftbuild. +if [[ -d "$resources/Contents/Resources" ]]; then resources="$resources/Contents/Resources"; fi +require_file "$resources/Resources/RayoMark.png" +test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")" = "${version%%[-+]*}" +test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app/Contents/Info.plist")" = RetroX +lipo "$app/Contents/MacOS/RetroX" -verify_arch "$arch" +codesign --verify --deep --strict "$app" +printf 'Pacote validado: %s\n' "$archive" diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..172645b --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "swiftui-design-skill": { + "source": "Wholiver/swiftui-design-skill", + "sourceType": "github", + "skillPath": "SKILL.md", + "computedHash": "e566cd9af70769555a866c11a8037f326a699f600de200a9dde8518122fb0b32" + } + } +}