From b6162b0ca1d4c328fa06fc346baa64bb2382768b Mon Sep 17 00:00:00 2001 From: idevlab Date: Sat, 11 Apr 2026 20:36:05 +0800 Subject: [PATCH 1/3] feat: dependency checking, onboarding, Settings window, and release CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependency checking & onboarding: - DependencyChecker.swift: EnvironmentChecker enum runs Node.js and skills CLI checks concurrently via async let; fetches latest version from npm registry (registry.npmjs.org/skills/latest) in parallel. Enriches Process PATH with /opt/homebrew/bin:/usr/local/bin so Homebrew-installed Node is found from a GUI app context. - DependencyStatus struct: tracks node/skillsCLI Availability, latest version, hasUpdate flag, and semantic version comparison. - OnboardingView: sheet shown on first launch when dependencies are missing. Non-dismissable (interactiveDismissDisabled) when Node is absent (hard requirement); "Skip for Now" available when only skills CLI is not globally installed (npx auto-download still works). Each SetupStep shows install command with one-click clipboard copy and a "Learn more" link. - ContentView: triggers loadSkills + checkEnvironment concurrently on .task; re-checks silently on scenePhase .active (app foreground). Shows OnboardingView sheet when skillsReady is false. Settings window (⌘,): - SettingsView: two-tab Settings scene added to SkillsUIApp alongside WindowGroup. Same SkillsManager instance shared via .environment. - About tab: app icon preview (SwiftUI replica of programmatic icon), version from AppVersion.swift, links to skills.sh and GitHub. - Environment tab: live Node.js / npm / skills CLI availability rows with green checkmark or red ✕; latest npm version; one-click "Update skills CLI" button (npm update -g skills); "Check Again". - SkillsManager: adds dependencyStatus: DependencyStatus?, checkEnvironment(), and updateSkillsCLI() (runs npm update then re-checks). - SkillsSidebar: adds safeAreaInset footer showing current skills version; orange "v{latest}" badge that opens Settings when an update is available. Release CI (.github/workflows/release.yml): - Triggers on push to tags matching v[0-9]*. - Injects tag version into Sources/AppVersion.swift before building, so Settings → About shows the correct release version. - Builds with swift build -c release on macos-15. - Produces a tar.gz archive + SHA-256 checksum and uploads both to the GitHub Release (creates the release if not pre-created). - All ${{ }} expressions used only via env: vars in run: blocks to prevent expression injection. - AppVersion.swift committed with "dev" placeholder; CI overwrites it in-place without committing back. README updates: - Replaced external Apple CDN logo with assets/icon.svg (SVG replica of the programmatic blue-purple puzzle-piece icon). - Added Guided Setup and Settings & Versioning to feature table. - Updated Prerequisites: Node.js with onboarding note; removed manual skills CLI install requirement. - Added Download a Pre-built Binary section with checksum verification. - Added Releasing a New Version section (git tag v* workflow). - Updated Architecture file tree and Key Design Decisions. - Both README.md (English) and README_ZH.md (Chinese) updated in sync. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 62 +++++++++ README.md | 58 ++++++--- README_ZH.md | 49 ++++++-- Sources/AppVersion.swift | 2 + Sources/ContentView.swift | 20 ++- Sources/DependencyChecker.swift | 121 ++++++++++++++++++ Sources/OnboardingView.swift | 216 ++++++++++++++++++++++++++++++++ Sources/SettingsView.swift | 163 ++++++++++++++++++++++++ Sources/SkillsManager.swift | 21 ++++ Sources/SkillsSidebar.swift | 71 ++++++++--- Sources/SkillsUIApp.swift | 5 + assets/icon.svg | 37 ++++++ 12 files changed, 780 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 Sources/AppVersion.swift create mode 100644 Sources/DependencyChecker.swift create mode 100644 Sources/OnboardingView.swift create mode 100644 Sources/SettingsView.swift create mode 100644 assets/icon.svg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..73920db --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release + +on: + push: + tags: + - 'v[0-9]*' + +jobs: + build: + name: Build and upload release binary + runs-on: macos-15 + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract version from tag + id: version + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" + echo "TAG=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + + - name: Inject version into source + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + printf '// Auto-generated by CI. Do not edit.\nlet appVersion = "%s"\n' "${VERSION}" \ + > Sources/AppVersion.swift + + - name: Build release binary + run: swift build -c release + + - name: Create archive and checksum + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + ARCHIVE="SkillsUI-${VERSION}-macos-arm64.tar.gz" + cd .build/release + tar -czf "${ARCHIVE}" SkillsUI + shasum -a 256 "${ARCHIVE}" > "${ARCHIVE}.sha256" + + - name: Publish to GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.VERSION }} + TAG: ${{ steps.version.outputs.TAG }} + run: | + ARCHIVE=".build/release/SkillsUI-${VERSION}-macos-arm64.tar.gz" + CHECKSUM="${ARCHIVE}.sha256" + + # Create the release if it doesn't already exist + # (silently no-ops when pre-created from the GitHub UI) + gh release create "${TAG}" \ + --title "SkillsUI ${TAG}" \ + --generate-notes \ + 2>/dev/null || true + + # Upload binary + checksum (--clobber replaces if already uploaded) + gh release upload "${TAG}" "${ARCHIVE}" "${CHECKSUM}" --clobber diff --git a/README.md b/README.md index e7e0bcb..aa82b75 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- SkillsUI Icon + SkillsUI Icon

SkillsUI

@@ -17,7 +17,6 @@

Features • - Screenshots • Getting Started • Architecture • Supported Agents • @@ -36,10 +35,8 @@ | 📂 | **Quick Actions** | Reveal in Finder, open on GitHub, view raw source, or remove skills instantly | | 🔗 | **Symlink Aware** | Detects and displays whether skills are symlinks or copies | | 🏷️ | **Smart Grouping** | Auto-groups skills by source repository with contextual icons | - -## Screenshots - -> _Build and run the app to see the full UI — a `NavigationSplitView` with sidebar + detail, plus a Marketplace tab._ +| 🛠️ | **Guided Setup** | Detects Node.js and skills CLI on launch; walks you through installation if anything is missing | +| ⚙️ | **Settings & Versioning** | Settings window (⌘,) shows runtime versions, checks for skills CLI updates, and offers one-click upgrade | ## Getting Started @@ -47,7 +44,9 @@ - **macOS 26** (Tahoe) or later - **Swift 6.2+** toolchain (ships with Xcode 26+) -- [`npx skills`](https://skills.sh) CLI installed globally (`npm i -g skills`) +- **Node.js** — SkillsUI will guide you through installation on first launch if it's missing + +> **Tip:** You don't need to install the skills CLI manually. SkillsUI detects whether it's present and offers installation guidance in the onboarding sheet and in **Settings → Environment**. ### Build & Run @@ -61,8 +60,6 @@ swift build # Run swift run SkillsUI -# — or — -open .build/debug/SkillsUI ``` ### Build for Release @@ -72,22 +69,38 @@ swift build -c release cp .build/release/SkillsUI /usr/local/bin/ ``` +> **Note:** Binaries built locally are unsigned. If macOS blocks the app, run `xattr -cr SkillsUI` once to clear the quarantine flag. + +### Download a Pre-built Binary + +Pre-built binaries (arm64) are attached to every [GitHub Release](https://github.com/IchenDEV/skills-ui/releases). Each release includes a `.sha256` checksum file for verification. + +```bash +# Verify checksum after download +shasum -a 256 -c SkillsUI-*.tar.gz.sha256 +``` + ## Architecture ``` Sources/ -├── SkillsUIApp.swift # @main entry, window config, app icon -├── ContentView.swift # TabView (Installed / Marketplace) -├── SkillsSidebar.swift # Sidebar list, grouped by source +├── SkillsUIApp.swift # @main entry, window config, app icon, Settings scene +├── ContentView.swift # TabView (Installed / Marketplace) + onboarding trigger +├── SkillsSidebar.swift # Sidebar list, grouped by source, version footer ├── SkillDetailView.swift # Detail pane — metadata grid + SKILL.md ├── AddSkillSheet.swift # Sheet for adding skills from GitHub ├── MarketplaceView.swift # Marketplace search UI ├── MarketplaceService.swift # skills.sh API client (actor) ├── MarketplaceSkill.swift # Marketplace data model -├── SkillsManager.swift # Core state — scanning, install, remove +├── SkillsManager.swift # Core state — scanning, install, remove, env check ├── Skill.swift # Installed skill model +├── Skill+UI.swift # SwiftUI extension: skillColor ├── SkillParser.swift # SKILL.md YAML frontmatter parser -└── SkillLock.swift # .skill-lock.json Codable types +├── SkillLock.swift # .skill-lock.json Codable types +├── DependencyChecker.swift # DependencyStatus model + EnvironmentChecker +├── OnboardingView.swift # Setup sheet shown when Node/skills CLI is missing +├── SettingsView.swift # Settings window (About + Environment tabs) +└── AppVersion.swift # Version string — overwritten by CI on release builds ``` ### Key Design Decisions @@ -95,12 +108,10 @@ Sources/ - **SwiftPM executable** — no Xcode project required; builds with `swift build` - **`@Observable` + actors** — Swift 6 strict concurrency throughout - **No third-party UI deps** — pure SwiftUI with native macOS look & feel -- **`swift-markdown`** — only dependency, used for Markdown rendering +- **Version baked at build time** — CI overwrites `AppVersion.swift` with the Release tag before `swift build -c release` ## Supported Agents -SkillsUI can install and detect skills for the following AI coding agents: - | Agent | Skills Path | |---|---| | Amp | `~/.agents/skills` | @@ -143,12 +154,23 @@ SkillsUI can install and detect skills for the following AI coding agents: └──────────────┘ ``` +## Releasing a New Version + +Push a tag matching `v*` and the CI workflow builds the binary and attaches it to the GitHub Release automatically: + +```bash +git tag v1.2.3 +git push origin v1.2.3 +``` + +The workflow injects the tag version into `AppVersion.swift` before building, so **Settings → About** always shows the correct release version. + ## Contributing 1. Fork the repository 2. Create a feature branch (`git checkout -b feature/awesome`) 3. Commit your changes (`git commit -m 'Add awesome feature'`) -4. Push to the branch (`git push origin feature/awesome`) +4. Push the branch (`git push origin feature/awesome`) 5. Open a Pull Request ## License diff --git a/README_ZH.md b/README_ZH.md index f80b9f1..5558aab 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -1,5 +1,5 @@

- SkillsUI Icon + SkillsUI 图标

SkillsUI

@@ -35,6 +35,8 @@ | 📂 | **快捷操作** | 在 Finder 中打开、跳转 GitHub、查看源码、移除技能 | | 🔗 | **符号链接感知** | 自动检测并展示技能是符号链接还是拷贝 | | 🏷️ | **智能分组** | 按来源仓库自动分组,配以语境化图标 | +| 🛠️ | **引导式安装** | 首次启动时检测 Node.js 与 skills CLI;如有缺失,提供分步安装引导 | +| ⚙️ | **设置与版本管理** | 设置窗口(⌘,)展示运行时版本、检查 skills CLI 更新,并提供一键升级 | ## 快速开始 @@ -42,7 +44,9 @@ - **macOS 26**(Tahoe)或更高版本 - **Swift 6.2+** 工具链(随 Xcode 26+ 附带) -- [`npx skills`](https://skills.sh) CLI 已全局安装(`npm i -g skills`) +- **Node.js** — 若首次启动时未检测到,SkillsUI 会引导你完成安装 + +> **提示:** 无需手动安装 skills CLI。SkillsUI 会在启动引导和**设置 → 运行环境**页面中检测并提供安装指引。 ### 构建并运行 @@ -56,8 +60,6 @@ swift build # 运行 swift run SkillsUI -# — 或者 — -open .build/debug/SkillsUI ``` ### Release 构建 @@ -67,22 +69,38 @@ swift build -c release cp .build/release/SkillsUI /usr/local/bin/ ``` +> **注意:** 本地构建的二进制文件未经签名。若 macOS 阻止运行,执行一次 `xattr -cr SkillsUI` 清除隔离标志即可。 + +### 下载预构建二进制 + +每个 [GitHub Release](https://github.com/IchenDEV/skills-ui/releases) 都附带已构建好的 arm64 二进制文件及 `.sha256` 校验文件。 + +```bash +# 下载后验证校验和 +shasum -a 256 -c SkillsUI-*.tar.gz.sha256 +``` + ## 项目结构 ``` Sources/ -├── SkillsUIApp.swift # @main 入口,窗口配置,应用图标 -├── ContentView.swift # TabView(已安装 / 应用市场) -├── SkillsSidebar.swift # 侧栏列表,按来源分组 +├── SkillsUIApp.swift # @main 入口,窗口配置,应用图标,Settings 场景 +├── ContentView.swift # TabView(已安装 / 应用市场)+ 引导触发 +├── SkillsSidebar.swift # 侧栏列表,按来源分组,底部版本角标 ├── SkillDetailView.swift # 详情面板 — 元数据网格 + SKILL.md ├── AddSkillSheet.swift # 从 GitHub 添加技能的弹窗 ├── MarketplaceView.swift # 应用市场搜索界面 ├── MarketplaceService.swift # skills.sh API 客户端(actor) ├── MarketplaceSkill.swift # 市场数据模型 -├── SkillsManager.swift # 核心状态管理 — 扫描、安装、移除 +├── SkillsManager.swift # 核心状态管理 — 扫描、安装、移除、环境检查 ├── Skill.swift # 已安装技能模型 +├── Skill+UI.swift # SwiftUI 扩展:skillColor ├── SkillParser.swift # SKILL.md YAML frontmatter 解析器 -└── SkillLock.swift # .skill-lock.json Codable 类型 +├── SkillLock.swift # .skill-lock.json Codable 类型 +├── DependencyChecker.swift # DependencyStatus 模型 + EnvironmentChecker +├── OnboardingView.swift # 缺少 Node / skills CLI 时显示的安装引导弹窗 +├── SettingsView.swift # 设置窗口(关于 + 运行环境 两个标签页) +└── AppVersion.swift # 版本字符串 — Release 构建时由 CI 覆写 ``` ### 设计理念 @@ -90,7 +108,16 @@ Sources/ - **SwiftPM 可执行文件** — 无需 Xcode 项目,`swift build` 即可构建 - **`@Observable` + actor** — 全面采用 Swift 6 严格并发 - **零第三方 UI 依赖** — 纯 SwiftUI,原生 macOS 风格 -- **`swift-markdown`** — 唯一依赖,用于 Markdown 渲染 +- **构建时注入版本号** — CI 在 `swift build -c release` 之前将 Release Tag 写入 `AppVersion.swift`,确保**设置 → 关于**中始终显示正确版本 + +## 发布新版本 + +推送符合 `v*` 格式的 Tag,CI 工作流将自动构建二进制文件并上传到对应的 GitHub Release: + +```bash +git tag v1.2.3 +git push origin v1.2.3 +``` ## 工作原理 @@ -113,8 +140,6 @@ Sources/ ## 支持的 Agent -SkillsUI 可以安装和检测以下 AI 编程助手的技能: - | Agent | 技能路径 | |---|---| | Amp | `~/.agents/skills` | diff --git a/Sources/AppVersion.swift b/Sources/AppVersion.swift new file mode 100644 index 0000000..7d83915 --- /dev/null +++ b/Sources/AppVersion.swift @@ -0,0 +1,2 @@ +// Updated automatically by the release CI workflow. Do not edit manually. +let appVersion = "dev" diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index ec5eda8..2844948 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -14,10 +14,12 @@ enum AppTab: String, CaseIterable { struct ContentView: View { @Environment(SkillsManager.self) private var manager + @Environment(\.scenePhase) private var scenePhase @State private var selectedTab: AppTab = .installed @State private var selectedSkill: Skill? @State private var searchText = "" @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var showOnboarding = false var body: some View { TabView(selection: $selectedTab) { @@ -28,7 +30,11 @@ struct ContentView: View { if let skill = selectedSkill { SkillDetailView(skill: skill) } else { - ContentUnavailableView("Select a Skill", systemImage: "puzzlepiece.extension", description: Text("Choose a skill from the sidebar to view its details.")) + ContentUnavailableView( + "Select a Skill", + systemImage: "puzzlepiece.extension", + description: Text("Choose a skill from the sidebar to view its details.") + ) } } .searchable(text: $searchText, placement: .sidebar, prompt: "Filter skills") @@ -42,6 +48,18 @@ struct ContentView: View { } .task { await manager.loadSkills() + await manager.checkEnvironment() + if !(manager.dependencyStatus?.skillsReady ?? true) { + showOnboarding = true + } + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { + Task { await manager.checkEnvironment() } + } + } + .sheet(isPresented: $showOnboarding) { + OnboardingView() } } } diff --git a/Sources/DependencyChecker.swift b/Sources/DependencyChecker.swift new file mode 100644 index 0000000..5bedb42 --- /dev/null +++ b/Sources/DependencyChecker.swift @@ -0,0 +1,121 @@ +import Foundation + +// MARK: - DependencyStatus + +struct DependencyStatus: Sendable { + enum Availability: Sendable, Equatable { + case installed(version: String) + case notInstalled + } + + var node: Availability = .notInstalled + var skillsCLI: Availability = .notInstalled + var latestSkillsVersion: String? + + /// Node.js is the hard runtime requirement (npx ships with it). + var nodeReady: Bool { + if case .installed = node { return true } + return false + } + + /// skills globally installed is recommended; npx auto-downloads as a fallback. + var skillsReady: Bool { + if case .installed = skillsCLI { return true } + return false + } + + var hasUpdate: Bool { + guard case .installed(let current) = skillsCLI, + let latest = latestSkillsVersion else { return false } + return isNewerVersion(latest, than: current) + } + + var currentSkillsVersion: String? { + if case .installed(let v) = skillsCLI { return v } + return nil + } + + var nodeVersion: String? { + if case .installed(let v) = node { return v } + return nil + } +} + +/// Semantic version comparison: returns true if `a` is strictly newer than `b`. +private func isNewerVersion(_ a: String, than b: String) -> Bool { + let av = a.split(separator: ".").compactMap { Int($0) } + let bv = b.split(separator: ".").compactMap { Int($0) } + for (x, y) in zip(av, bv) where x != y { return x > y } + return av.count > bv.count +} + +// MARK: - EnvironmentChecker + +enum EnvironmentChecker { + /// Runs all checks concurrently and returns a fully-populated status. + static func check() async -> DependencyStatus { + async let nodeAv = checkNode() + async let skillsAv = checkSkillsCLI() + async let latestVer = fetchLatestSkillsVersion() + return await DependencyStatus(node: nodeAv, skillsCLI: skillsAv, latestSkillsVersion: latestVer) + } + + private static func checkNode() async -> DependencyStatus.Availability { + await Task.detached { + guard let v = shellOutput(["node", "--version"]) else { return .notInstalled } + return .installed(version: v) + }.value + } + + private static func checkSkillsCLI() async -> DependencyStatus.Availability { + await Task.detached { + // npm list exits 1 when packages are missing but still outputs partial JSON — + // parse the output regardless of exit code. + let process = makeProcess(args: ["npm", "list", "-g", "skills", "--depth=0", "--json"]) + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + try? process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let deps = json["dependencies"] as? [String: Any], + let info = deps["skills"] as? [String: Any], + let version = info["version"] as? String else { return .notInstalled } + return .installed(version: version) + }.value + } + + private static func fetchLatestSkillsVersion() async -> String? { + guard let url = URL(string: "https://registry.npmjs.org/skills/latest") else { return nil } + guard let (data, _) = try? await URLSession.shared.data(from: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = json["version"] as? String else { return nil } + return version + } + + private static func shellOutput(_ args: [String]) -> String? { + let process = makeProcess(args: args) + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + try? process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Returns a Process with an enriched PATH covering common Homebrew install locations, + /// so that GUI app context (which inherits a minimal PATH) can find node/npm. + static func makeProcess(args: [String]) -> Process { + let process = Process() + process.executableURL = URL(filePath: "/usr/bin/env") + process.arguments = args + var env = ProcessInfo.processInfo.environment + let extra = "/opt/homebrew/bin:/usr/local/bin" + env["PATH"] = [extra, env["PATH"] ?? "/usr/bin:/bin"].joined(separator: ":") + process.environment = env + return process + } +} diff --git a/Sources/OnboardingView.swift b/Sources/OnboardingView.swift new file mode 100644 index 0000000..344616e --- /dev/null +++ b/Sources/OnboardingView.swift @@ -0,0 +1,216 @@ +import SwiftUI + +struct OnboardingView: View { + @Environment(SkillsManager.self) private var manager + @Environment(\.dismiss) private var dismiss + @State private var isChecking = false + + /// Onboarding can be skipped only when Node is present. + /// If Node is missing, the sheet is locked until the user installs it. + private var canSkip: Bool { manager.dependencyStatus?.nodeReady == true } + + var body: some View { + VStack(spacing: 0) { + headerSection + Divider() + stepsSection + Spacer() + Divider() + footerSection + } + .frame(width: 500, height: 460) + // Prevent closing via Esc or swipe when Node is missing + .interactiveDismissDisabled(!canSkip) + } + + // MARK: - Sections + + private var headerSection: some View { + VStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(LinearGradient( + colors: [Color(red: 0.35, green: 0.35, blue: 0.95), + Color(red: 0.35, green: 0.35, blue: 0.95, opacity: 0.7)], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + .frame(width: 64, height: 64) + Image(systemName: "puzzlepiece.extension.fill") + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(.white) + } + + Text("Setup Required") + .font(.title2).fontWeight(.bold) + + Text("SkillsUI needs a few tools to manage AI agent skills.") + .font(.body).foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 40) + .padding(.top, 32) + .padding(.bottom, 24) + } + + private var stepsSection: some View { + VStack(alignment: .leading, spacing: 20) { + SetupStep( + number: 1, + title: "Node.js", + subtitle: "JavaScript runtime required to run the skills package manager.", + availability: manager.dependencyStatus?.node ?? .notInstalled, + installCommand: "brew install node", + learnMoreURL: "https://nodejs.org/en/download", + badge: "Required" + ) + SetupStep( + number: 2, + title: "skills CLI", + subtitle: "Installs and manages AI agent skills across all your coding tools.", + availability: manager.dependencyStatus?.skillsCLI ?? .notInstalled, + installCommand: "npm install -g skills", + learnMoreURL: "https://skills.sh", + badge: "Recommended" + ) + } + .padding(.horizontal, 32) + .padding(.vertical, 24) + } + + private var footerSection: some View { + HStack { + if canSkip { + Button("Skip for Now") { dismiss() } + .foregroundStyle(.secondary) + } + Spacer() + Button { + isChecking = true + Task { + await manager.checkEnvironment() + isChecking = false + if manager.dependencyStatus?.skillsReady == true { dismiss() } + } + } label: { + if isChecking { + ProgressView().controlSize(.small).padding(.horizontal, 8) + } else { + Label("Check Again", systemImage: "arrow.clockwise") + } + } + .buttonStyle(.borderedProminent) + .disabled(isChecking) + .keyboardShortcut(.defaultAction) + } + .padding(20) + } +} + +// MARK: - SetupStep + +private struct SetupStep: View { + @Environment(\.openURL) private var openURL + let number: Int + let title: String + let subtitle: String + let availability: DependencyStatus.Availability + let installCommand: String + let learnMoreURL: String + let badge: String + + @State private var copied = false + + private var isInstalled: Bool { + if case .installed = availability { return true } + return false + } + + private var isRequired: Bool { badge == "Required" } + + var body: some View { + HStack(alignment: .top, spacing: 16) { + stepBadge + VStack(alignment: .leading, spacing: 6) { + titleRow + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + if !isInstalled { installRow } + } + } + } + + private var stepBadge: some View { + ZStack { + Circle() + .fill(isInstalled + ? Color.green.opacity(0.15) + : (isRequired ? Color.red.opacity(0.12) : Color.orange.opacity(0.12))) + .frame(width: 36, height: 36) + if isInstalled { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.green) + } else { + Text("\(number)") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(isRequired ? .red : .orange) + } + } + } + + private var titleRow: some View { + HStack(spacing: 8) { + Text(title).fontWeight(.semibold) + switch availability { + case .installed(let version): + statusPill(version, color: .green) + case .notInstalled: + statusPill(badge, color: isRequired ? .red : .orange) + } + } + } + + private func statusPill(_ text: String, color: Color) -> some View { + Text(text) + .font(.caption) + .foregroundStyle(color) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(color.opacity(0.12), in: Capsule()) + } + + private var installRow: some View { + HStack(spacing: 8) { + Text(installCommand) + .font(.system(.caption, design: .monospaced)) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(installCommand, forType: .string) + copied = true + Task { + try? await Task.sleep(for: .seconds(2)) + copied = false + } + } label: { + Image(systemName: copied ? "checkmark.circle.fill" : "doc.on.doc") + .font(.caption) + .foregroundStyle(copied ? .green : .secondary) + } + .buttonStyle(.borderless) + .help("Copy to clipboard") + + Button { + if let url = URL(string: learnMoreURL) { openURL(url) } + } label: { + Image(systemName: "arrow.up.right.square") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .help("Learn more") + } + } +} diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift new file mode 100644 index 0000000..65e6301 --- /dev/null +++ b/Sources/SettingsView.swift @@ -0,0 +1,163 @@ +import SwiftUI + +struct SettingsView: View { + var body: some View { + TabView { + AboutTab() + .tabItem { Label("About", systemImage: "info.circle") } + + EnvironmentTab() + .tabItem { Label("Environment", systemImage: "wrench.and.screwdriver") } + } + .frame(width: 480) + } +} + +// MARK: - About Tab + +private struct AboutTab: View { + @Environment(\.openURL) private var openURL + + var body: some View { + Form { + Section { + HStack(spacing: 16) { + appIcon + VStack(alignment: .leading, spacing: 4) { + Text("SkillsUI") + .font(.title2).fontWeight(.bold) + Text("Version \(appVersion)") + .foregroundStyle(.secondary) + Text("macOS 26 · Swift 6 · MIT License") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 4) + } + + Section("Links") { + Link("skills.sh — Marketplace", + destination: URL(string: "https://skills.sh")!) + Link("GitHub Repository", + destination: URL(string: "https://github.com/IchenDEV/skills-ui")!) + Link("Report an Issue", + destination: URL(string: "https://github.com/IchenDEV/skills-ui/issues")!) + } + } + .formStyle(.grouped) + .frame(height: 280) + } + + private var appIcon: some View { + RoundedRectangle(cornerRadius: 14) + .fill(LinearGradient( + colors: [Color(red: 0.35, green: 0.35, blue: 0.95), + Color(red: 0.35, green: 0.35, blue: 0.95, opacity: 0.7)], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 64, height: 64) + .overlay { + Image(systemName: "puzzlepiece.extension.fill") + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(.white) + } + .shadow(color: .black.opacity(0.12), radius: 6, y: 3) + } +} + +// MARK: - Environment Tab + +private struct EnvironmentTab: View { + @Environment(SkillsManager.self) private var manager + @State private var isChecking = false + @State private var isUpdating = false + + var body: some View { + Form { + Section("Runtime") { + availabilityRow( + label: "Node.js", + availability: manager.dependencyStatus?.node ?? .notInstalled, + note: "brew install node · nodejs.org" + ) + availabilityRow( + label: "npm / npx", + availability: manager.dependencyStatus?.node ?? .notInstalled, + note: "Bundled with Node.js" + ) + } + + Section("skills CLI") { + availabilityRow( + label: "skills", + availability: manager.dependencyStatus?.skillsCLI ?? .notInstalled, + note: "npm install -g skills" + ) + + if let latest = manager.dependencyStatus?.latestSkillsVersion { + LabeledContent("Latest on npm", value: "v\(latest)") + } + + if manager.dependencyStatus?.hasUpdate == true, + let latest = manager.dependencyStatus?.latestSkillsVersion { + Button { + isUpdating = true + Task { + await manager.updateSkillsCLI() + isUpdating = false + } + } label: { + Label( + isUpdating ? "Updating…" : "Update to v\(latest)", + systemImage: "arrow.up.circle.fill" + ) + } + .disabled(isUpdating) + } + } + + Section { + Button { + isChecking = true + Task { + await manager.checkEnvironment() + isChecking = false + } + } label: { + Label( + isChecking ? "Checking…" : "Check Again", + systemImage: "arrow.clockwise" + ) + } + .disabled(isChecking) + } + } + .formStyle(.grouped) + .frame(height: 340) + } + + private func availabilityRow( + label: String, + availability: DependencyStatus.Availability, + note: String + ) -> some View { + LabeledContent(label) { + switch availability { + case .installed(let version): + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + Text(version).foregroundStyle(.secondary) + } + case .notInstalled: + HStack(spacing: 6) { + Image(systemName: "xmark.circle.fill").foregroundStyle(.red) + Text("Not found").foregroundStyle(.secondary) + Text("·").foregroundStyle(.quaternary) + Text(note).font(.caption).foregroundStyle(.tertiary) + } + } + } + } +} diff --git a/Sources/SkillsManager.swift b/Sources/SkillsManager.swift index 2473b62..4a70f4f 100644 --- a/Sources/SkillsManager.swift +++ b/Sources/SkillsManager.swift @@ -12,6 +12,7 @@ final class SkillsManager { var skills: [Skill] = [] var isLoading = false var errorMessage: String? + var dependencyStatus: DependencyStatus? private let homePath: String private let globalSkillsPath: String @@ -163,6 +164,26 @@ final class SkillsManager { await loadSkills() } + func checkEnvironment() async { + dependencyStatus = await EnvironmentChecker.check() + } + + func updateSkillsCLI() async { + do { + try await Task.detached { + let process = EnvironmentChecker.makeProcess(args: ["npm", "update", "-g", "skills"]) + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + throw CommandError(exitCode: process.terminationStatus) + } + }.value + } catch { + errorMessage = error.localizedDescription + } + await checkEnvironment() + } + func revealInFinder(_ skill: Skill) { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: skill.path) } diff --git a/Sources/SkillsSidebar.swift b/Sources/SkillsSidebar.swift index 036143a..ad1eba1 100644 --- a/Sources/SkillsSidebar.swift +++ b/Sources/SkillsSidebar.swift @@ -29,8 +29,7 @@ struct SkillsSidebar: View { } } header: { Label { - Text(group.source) - .fontWeight(.semibold) + Text(group.source).fontWeight(.semibold) } icon: { Image(systemName: group.source.contains("/") ? "shippingbox" : "folder") } @@ -40,23 +39,26 @@ struct SkillsSidebar: View { .listStyle(.sidebar) .overlay { if manager.isLoading { - ProgressView() - .controlSize(.large) + ProgressView().controlSize(.large) } else if manager.skills.isEmpty { - ContentUnavailableView("No Skills Installed", systemImage: "puzzlepiece.extension", description: Text("Add skills from a GitHub repository.")) + ContentUnavailableView( + "No Skills Installed", + systemImage: "puzzlepiece.extension", + description: Text("Add skills from a GitHub repository.") + ) + } + } + .safeAreaInset(edge: .bottom) { + if let status = manager.dependencyStatus { + VersionFooter(status: status) } } .toolbar { ToolbarItemGroup(placement: .primaryAction) { - Button { - showAddSheet = true - } label: { + Button { showAddSheet = true } label: { Label("Add Skill", systemImage: "plus") } - - Button { - Task { await manager.loadSkills() } - } label: { + Button { Task { await manager.loadSkills() } } label: { Label("Refresh", systemImage: "arrow.clockwise") } } @@ -69,6 +71,49 @@ struct SkillsSidebar: View { } } +// MARK: - Version Footer + +private struct VersionFooter: View { + let status: DependencyStatus + + var body: some View { + HStack(spacing: 6) { + if let version = status.currentSkillsVersion { + Label("skills v\(version)", systemImage: "shippingbox") + .font(.caption2) + .foregroundStyle(.tertiary) + } else { + Label("skills CLI not found", systemImage: "exclamationmark.triangle") + .font(.caption2) + .foregroundStyle(.orange) + } + + Spacer() + + if status.hasUpdate, let latest = status.latestSkillsVersion { + Button { + // Open the Settings window (Environment tab) + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } label: { + HStack(spacing: 3) { + Image(systemName: "arrow.up.circle.fill") + Text("v\(latest)") + } + .font(.caption2) + .foregroundStyle(.orange) + } + .buttonStyle(.borderless) + .help("Update skills to v\(latest) — open Settings") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.bar) + } +} + +// MARK: - SkillRow + struct SkillRow: View { let skill: Skill @@ -79,7 +124,6 @@ struct SkillRow: View { .foregroundStyle(skill.skillColor) .font(.system(size: 14, weight: .medium)) .frame(width: 20) - Text(skill.displayName) .font(.headline) .lineLimit(1) @@ -91,5 +135,4 @@ struct SkillRow: View { } .padding(.vertical, 2) } - } diff --git a/Sources/SkillsUIApp.swift b/Sources/SkillsUIApp.swift index b6c28fa..f386bb9 100644 --- a/Sources/SkillsUIApp.swift +++ b/Sources/SkillsUIApp.swift @@ -62,5 +62,10 @@ struct SkillsUIApp: App { } .windowStyle(.automatic) .defaultSize(width: 1000, height: 680) + + Settings { + SettingsView() + .environment(skillsManager) + } } } diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..b9381e4 --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 308cc37f3f03f33cbc0ae06e71e0373bef369d54 Mon Sep 17 00:00:00 2001 From: idevlab Date: Fri, 24 Apr 2026 00:18:36 +0800 Subject: [PATCH 2/3] polish branding and refresh actions runtimes --- .github/workflows/build.yml | 22 +++--- .github/workflows/release.yml | 7 +- README.md | 41 ++++------ README_ZH.md | 37 ++++----- Sources/SkillsUIApp.swift | 142 ++++++++++++++++++++++++++-------- assets/icon.svg | 58 +++++++------- assets/logo.svg | 53 +++++++++++++ 7 files changed, 239 insertions(+), 121 deletions(-) create mode 100644 assets/logo.svg diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4c3a284..cfccde9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,13 +14,15 @@ jobs: build: name: Build & Test runs-on: macos-15 + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - name: Select Xcode 26 - uses: maxim-lobanov/setup-xcode@v1 + - name: Select latest stable Xcode + uses: maxim-lobanov/setup-xcode@v1.7.0 with: - xcode-version: "26" + xcode-version: latest-stable - name: Print toolchain info run: | @@ -41,19 +43,21 @@ jobs: needs: build if: github.ref == 'refs/heads/main' && github.event_name == 'push' runs-on: macos-15 + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - name: Select Xcode 26 - uses: maxim-lobanov/setup-xcode@v1 + - name: Select latest stable Xcode + uses: maxim-lobanov/setup-xcode@v1.7.0 with: - xcode-version: "26" + xcode-version: latest-stable - name: Build release binary run: swift build -c release - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: SkillsUI-macos path: .build/release/SkillsUI diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73920db..1a90347 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 + + - name: Select latest stable Xcode + uses: maxim-lobanov/setup-xcode@v1.7.0 + with: + xcode-version: latest-stable - name: Extract version from tag id: version diff --git a/README.md b/README.md index aa82b75..e54e2d1 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,23 @@

- SkillsUI Icon + SkillsUI logo

-

SkillsUI

+

+ A native macOS app for managing AI agent skills without living in dotfiles and symlinks. +

- A native macOS app to manage AI agent skills — browse, install, and organize skills.sh packages across all your coding agents. + Browse installed skills, search skills.sh, inspect rendered SKILL.md, and send the same package to Codex, Claude Code, Cursor, Windsurf, and the rest of your local agent setup.

Swift 6 macOS 26+ SwiftUI - License

- Features • + What It Does • Getting Started • Architecture • Supported Agents • @@ -25,18 +26,14 @@ --- -## Features +## What It Does -| | Feature | Description | -|---|---|---| -| 🧩 | **Skill Browser** | View all globally installed skills with rich metadata, source info, and rendered SKILL.md content | -| 🔍 | **Marketplace Search** | Search [skills.sh](https://skills.sh) directly from the app and install with one click | -| 🤖 | **Multi-Agent Support** | Install skills to 20+ agents — Claude Code, Cursor, Codex, Windsurf, Gemini CLI, and more | -| 📂 | **Quick Actions** | Reveal in Finder, open on GitHub, view raw source, or remove skills instantly | -| 🔗 | **Symlink Aware** | Detects and displays whether skills are symlinks or copies | -| 🏷️ | **Smart Grouping** | Auto-groups skills by source repository with contextual icons | -| 🛠️ | **Guided Setup** | Detects Node.js and skills CLI on launch; walks you through installation if anything is missing | -| ⚙️ | **Settings & Versioning** | Settings window (⌘,) shows runtime versions, checks for skills CLI updates, and offers one-click upgrade | +- Shows every installed skill from the shared `~/.agents/skills` store, grouped by source repo. +- Renders `SKILL.md` content so you can inspect a package before wiring it into another agent. +- Searches the [skills.sh](https://skills.sh) marketplace inside the app and installs packages with one click. +- Detects per-agent links across 20 supported agent directories, so you can see where each skill is actually available. +- Gives you the day-to-day actions you need: reveal in Finder, open the source, remove a skill, or refresh the index. +- Checks Node.js and the `skills` CLI on launch, then points you to the missing piece when your environment is incomplete. ## Getting Started @@ -47,6 +44,8 @@ - **Node.js** — SkillsUI will guide you through installation on first launch if it's missing > **Tip:** You don't need to install the skills CLI manually. SkillsUI detects whether it's present and offers installation guidance in the onboarding sheet and in **Settings → Environment**. +> +> **Why Node at all?** The app itself is native SwiftUI, but install/remove/update operations call `npx skills` under the hood, so Node.js is a runtime dependency for those actions rather than for building the macOS app. ### Build & Run @@ -172,13 +171,3 @@ The workflow injects the tag version into `AppVersion.swift` before building, so 3. Commit your changes (`git commit -m 'Add awesome feature'`) 4. Push the branch (`git push origin feature/awesome`) 5. Open a Pull Request - -## License - -This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details. - ---- - -

- Built with ❤️ and SwiftUI -

diff --git a/README_ZH.md b/README_ZH.md index 5558aab..9e89374 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -1,18 +1,19 @@

- SkillsUI 图标 + SkillsUI logo

-

SkillsUI

+

+ 一款原生 macOS 应用,把 AI agent 的技能管理这件事,从点文件和符号链接里拎出来。 +

- 一款原生 macOS 应用,用于管理 AI 编程助手的技能包 — 浏览、安装并将 skills.sh 上的技能分发到你所有的 Coding Agent。 + 你可以在一个界面里查看已安装技能、搜索 skills.sh、阅读渲染后的 SKILL.md,再把同一个技能分发到 Codex、Claude Code、Cursor、Windsurf 等本地 agent。

Swift 6 macOS 26+ SwiftUI - License

@@ -27,16 +28,12 @@ ## 功能特性 -| | 功能 | 说明 | -|---|---|---| -| 🧩 | **技能浏览器** | 查看所有已安装的全局技能,包含丰富的元数据、来源信息和渲染后的 SKILL.md | -| 🔍 | **应用内搜索** | 直接在 App 内搜索 [skills.sh](https://skills.sh),一键安装 | -| 🤖 | **多 Agent 支持** | 支持向 20+ 个 Agent 安装技能 — Claude Code、Cursor、Codex、Windsurf、Gemini CLI 等 | -| 📂 | **快捷操作** | 在 Finder 中打开、跳转 GitHub、查看源码、移除技能 | -| 🔗 | **符号链接感知** | 自动检测并展示技能是符号链接还是拷贝 | -| 🏷️ | **智能分组** | 按来源仓库自动分组,配以语境化图标 | -| 🛠️ | **引导式安装** | 首次启动时检测 Node.js 与 skills CLI;如有缺失,提供分步安装引导 | -| ⚙️ | **设置与版本管理** | 设置窗口(⌘,)展示运行时版本、检查 skills CLI 更新,并提供一键升级 | +- 扫描共享的 `~/.agents/skills` 仓库,并按来源仓库分组展示已安装技能。 +- 直接渲染 `SKILL.md`,装之前先看清楚这个技能到底做什么。 +- 在 App 里搜索 [skills.sh](https://skills.sh),找到后可以一键安装。 +- 检测 20 个 agent 目录里的链接关系,知道每个技能到底挂到了哪里。 +- 常用操作都在界面里:Finder、源码链接、移除、刷新,不用回到命令行找路径。 +- 启动时检查 Node.js 和 `skills` CLI,环境缺什么就提示什么。 ## 快速开始 @@ -47,6 +44,8 @@ - **Node.js** — 若首次启动时未检测到,SkillsUI 会引导你完成安装 > **提示:** 无需手动安装 skills CLI。SkillsUI 会在启动引导和**设置 → 运行环境**页面中检测并提供安装指引。 +> +> **为什么这里会有 Node?** App 本身是原生 SwiftUI,但安装、移除、更新技能时底层调用的是 `npx skills`,所以 Node.js 是这些操作的运行时依赖,不是构建这个 macOS 应用本身所必需的语言栈。 ### 构建并运行 @@ -170,13 +169,3 @@ git push origin v1.2.3 3. 提交改动(`git commit -m 'Add awesome feature'`) 4. 推送分支(`git push origin feature/awesome`) 5. 发起 Pull Request - -## 许可证 - -本项目采用 MIT 许可证 — 详见 [LICENSE](LICENSE) 文件。 - ---- - -

- 用 ❤️ 和 SwiftUI 构建 -

diff --git a/Sources/SkillsUIApp.swift b/Sources/SkillsUIApp.swift index f386bb9..5a6342b 100644 --- a/Sources/SkillsUIApp.swift +++ b/Sources/SkillsUIApp.swift @@ -10,45 +10,121 @@ struct SkillsUIApp: App { NSApplication.shared.setActivationPolicy(.regular) NSApplication.shared.activate() - // Create app icon — 1024×1024, white bg, matching standard macOS icon size + // Create a custom app icon so the README logo and runtime icon stay aligned. let iconSize = NSSize(width: 1024, height: 1024) let iconImage = NSImage(size: iconSize, flipped: false) { rect in - // macOS icon inset: ~824px centered in 1024 with ~100px margin let inset: CGFloat = 100 - let iconRect = rect.insetBy(dx: inset, dy: inset) + let plateRect = rect.insetBy(dx: inset, dy: inset) let cornerRadius: CGFloat = 185 - // White background - NSColor.white.setFill() - NSBezierPath(roundedRect: iconRect, xRadius: cornerRadius, yRadius: cornerRadius).fill() - - // Subtle shadow - let shadow = NSShadow() - shadow.shadowColor = NSColor.black.withAlphaComponent(0.15) - shadow.shadowBlurRadius = 20 - shadow.shadowOffset = NSSize(width: 0, height: -8) - shadow.set() - NSColor.white.setFill() - NSBezierPath(roundedRect: iconRect, xRadius: cornerRadius, yRadius: cornerRadius).fill() - NSShadow().set() // reset - - // Gradient symbol - let config = NSImage.SymbolConfiguration(pointSize: 380, weight: .medium) - if let symbol = NSImage(systemSymbolName: "puzzlepiece.extension.fill", accessibilityDescription: nil)? - .withSymbolConfiguration(config) { - let symbolSize = symbol.size - let origin = NSPoint( - x: (rect.width - symbolSize.width) / 2, - y: (rect.height - symbolSize.height) / 2 + let platePath = NSBezierPath(roundedRect: plateRect, xRadius: cornerRadius, yRadius: cornerRadius) + let plateShadow = NSShadow() + plateShadow.shadowColor = NSColor.black.withAlphaComponent(0.16) + plateShadow.shadowBlurRadius = 28 + plateShadow.shadowOffset = NSSize(width: 0, height: -10) + plateShadow.set() + + let plateGradient = NSGradient(colorsAndLocations: + (NSColor(srgbRed: 1.0, green: 0.976, blue: 0.933, alpha: 1.0), 0.0), + (NSColor(srgbRed: 0.933, green: 0.969, blue: 0.98, alpha: 1.0), 1.0) + ) + plateGradient?.draw(in: platePath, angle: -90) + NSShadow().set() + + NSColor(srgbRed: 0.843, green: 0.89, blue: 0.918, alpha: 1.0).setStroke() + platePath.lineWidth = 10 + platePath.stroke() + + func fillRoundedRect(_ rect: NSRect, radius: CGFloat, color: NSColor) { + color.setFill() + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() + } + + func strokeRoundedRect(_ rect: NSRect, radius: CGFloat, color: NSColor, width: CGFloat) { + let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) + path.lineWidth = width + color.setStroke() + path.stroke() + } + + func drawCard(_ rect: NSRect, fillColor: NSColor, strokeColor: NSColor, accentColor: NSColor) { + let cardShadow = NSShadow() + cardShadow.shadowColor = NSColor.black.withAlphaComponent(0.08) + cardShadow.shadowBlurRadius = 12 + cardShadow.shadowOffset = NSSize(width: 0, height: -4) + cardShadow.set() + fillRoundedRect(rect, radius: 48, color: fillColor) + NSShadow().set() + + strokeRoundedRect(rect, radius: 48, color: strokeColor, width: 6) + + let accentRect = NSRect( + x: rect.minX + 54, + y: rect.midY - 36, + width: 58, + height: 72 ) - // Draw with blue-purple tint - let tint = NSColor(red: 0.35, green: 0.35, blue: 0.95, alpha: 1.0) - let tinted = symbol.copy() as! NSImage - tinted.lockFocus() - tint.set() - NSRect(origin: .zero, size: symbolSize).fill(using: .sourceAtop) - tinted.unlockFocus() - tinted.draw(at: origin, from: .zero, operation: .sourceOver, fraction: 1.0) + fillRoundedRect(accentRect, radius: 28, color: accentColor) + + fillRoundedRect( + NSRect(x: rect.minX + 144, y: rect.midY + 10, width: 176, height: 24), + radius: 12, + color: NSColor(srgbRed: 0.09, green: 0.19, blue: 0.26, alpha: 0.22) + ) + fillRoundedRect( + NSRect(x: rect.minX + 144, y: rect.midY - 28, width: 124, height: 24), + radius: 12, + color: NSColor(srgbRed: 0.09, green: 0.19, blue: 0.26, alpha: 0.14) + ) + } + + let backCard = NSRect(x: plateRect.minX + 120, y: plateRect.maxY - 270, width: 410, height: 180) + let middleCard = NSRect(x: plateRect.minX + 200, y: plateRect.maxY - 430, width: 452, height: 180) + let frontCard = NSRect(x: plateRect.minX + 280, y: plateRect.maxY - 590, width: 410, height: 180) + + drawCard( + backCard, + fillColor: NSColor(srgbRed: 0.875, green: 0.961, blue: 0.918, alpha: 1.0), + strokeColor: NSColor(srgbRed: 0.776, green: 0.906, blue: 0.859, alpha: 1.0), + accentColor: NSColor(srgbRed: 0.122, green: 0.616, blue: 0.463, alpha: 1.0) + ) + drawCard( + middleCard, + fillColor: NSColor(srgbRed: 0.867, green: 0.922, blue: 1.0, alpha: 1.0), + strokeColor: NSColor(srgbRed: 0.78, green: 0.852, blue: 0.961, alpha: 1.0), + accentColor: NSColor(srgbRed: 0.231, green: 0.51, blue: 0.965, alpha: 1.0) + ) + drawCard( + frontCard, + fillColor: NSColor(srgbRed: 1.0, green: 0.969, blue: 0.91, alpha: 1.0), + strokeColor: NSColor(srgbRed: 0.941, green: 0.871, blue: 0.757, alpha: 1.0), + accentColor: NSColor(srgbRed: 0.961, green: 0.62, blue: 0.043, alpha: 1.0) + ) + + let badgeRect = NSRect(x: plateRect.maxX - 214, y: plateRect.maxY - 214, width: 132, height: 132) + fillRoundedRect(badgeRect, radius: 66, color: NSColor(srgbRed: 0.059, green: 0.463, blue: 0.431, alpha: 1.0)) + + let sparkleCenter = NSPoint(x: badgeRect.midX, y: badgeRect.midY) + let sparklePath = NSBezierPath() + sparklePath.move(to: NSPoint(x: sparkleCenter.x, y: sparkleCenter.y + 34)) + sparklePath.line(to: NSPoint(x: sparkleCenter.x + 12, y: sparkleCenter.y + 12)) + sparklePath.line(to: NSPoint(x: sparkleCenter.x + 34, y: sparkleCenter.y)) + sparklePath.line(to: NSPoint(x: sparkleCenter.x + 12, y: sparkleCenter.y - 12)) + sparklePath.line(to: NSPoint(x: sparkleCenter.x, y: sparkleCenter.y - 34)) + sparklePath.line(to: NSPoint(x: sparkleCenter.x - 12, y: sparkleCenter.y - 12)) + sparklePath.line(to: NSPoint(x: sparkleCenter.x - 34, y: sparkleCenter.y)) + sparklePath.line(to: NSPoint(x: sparkleCenter.x - 12, y: sparkleCenter.y + 12)) + sparklePath.close() + NSColor(srgbRed: 1.0, green: 0.969, blue: 0.839, alpha: 1.0).setFill() + sparklePath.fill() + + if let shine = NSGradient(colorsAndLocations: + (NSColor.white.withAlphaComponent(0.22), 0.0), + (NSColor.white.withAlphaComponent(0.0), 1.0) + ) { + let shineRect = NSRect(x: plateRect.minX + 34, y: plateRect.maxY - 150, width: 280, height: 92) + let shinePath = NSBezierPath(roundedRect: shineRect, xRadius: 34, yRadius: 34) + shine.draw(in: shinePath, angle: -90) } return true } diff --git a/assets/icon.svg b/assets/icon.svg index b9381e4..4be937b 100644 --- a/assets/icon.svg +++ b/assets/icon.svg @@ -1,37 +1,39 @@ - + - - - + + + + + + + + - - - - - + + + + - - + + + + + - - - - - - + + + + + - - - - - - + + + + + - - + + diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..c5d081b --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SkillsUI + From b3175289323ba630caa0a4d97613d12cfd412360 Mon Sep 17 00:00:00 2001 From: idevlab Date: Fri, 24 Apr 2026 00:20:49 +0800 Subject: [PATCH 3/3] switch workflows to macos-26 runners --- .github/workflows/build.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cfccde9..26bd377 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ concurrency: jobs: build: name: Build & Test - runs-on: macos-15 + runs-on: macos-26 permissions: contents: read steps: @@ -42,7 +42,7 @@ jobs: name: Create Release Artifact needs: build if: github.ref == 'refs/heads/main' && github.event_name == 'push' - runs-on: macos-15 + runs-on: macos-26 permissions: contents: read steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a90347..b245837 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: jobs: build: name: Build and upload release binary - runs-on: macos-15 + runs-on: macos-26 permissions: contents: write