MioIsland/ClaudeIsland/UI/Views/PluginStoreView.swift
xmqywx ace93c3df0 feat(plugins): add Install button with URL and folder import
- Install from URL: paste a plugin.json URL to download and install
- Install from Folder: pick a local plugin directory via NSOpenPanel
- Popover UI with status feedback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:22:17 +08:00

462 lines
17 KiB
Swift

//
// PluginStoreView.swift
// ClaudeIsland
//
// Plugin Store tab in System Settings. Shows installed themes,
// buddies, and sounds with apply/uninstall actions.
//
import SwiftUI
struct PluginStoreView: View {
@ObservedObject private var pluginManager = PluginManager.shared
@ObservedObject private var themeRegistry = ThemeRegistry.shared
@ObservedObject private var buddyRegistry = BuddyRegistry.shared
@ObservedObject private var store = NotchCustomizationStore.shared
@ObservedObject private var downloader = PluginDownloader.shared
@State private var downloadingId: String?
@State private var showInstallSheet = false
@State private var installURL = ""
@State private var installStatus: String?
@State private var isInstalling = false
@State private var selectedCategory = 0
private let categories = ["Themes", "Buddies", "Sounds"]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Picker("", selection: $selectedCategory) {
ForEach(0..<categories.count, id: \.self) { i in
Text(categories[i]).tag(i)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 300)
Spacer()
Button {
showInstallSheet.toggle()
} label: {
HStack(spacing: 4) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 12))
Text("Install")
.font(.system(size: 11, weight: .semibold))
}
.foregroundColor(.green)
}
.buttonStyle(.plain)
}
switch selectedCategory {
case 0: themesSection
case 1: buddiesSection
case 2: soundsSection
default: EmptyView()
}
// Available from registry
if !downloader.notInstalled.isEmpty {
Divider().opacity(0.2)
Text("Available")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white.opacity(0.5))
ForEach(downloader.notInstalled) { entry in
availableRow(entry)
}
}
Spacer()
pluginDirHint
}
.padding(20)
.task {
await downloader.fetchRegistry()
}
.popover(isPresented: $showInstallSheet, arrowEdge: .bottom) {
installSheet
}
}
// MARK: - Themes
private var themesSection: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))], spacing: 12) {
ForEach(themeRegistry.themes) { theme in
themeCard(theme)
}
}
}
private func themeCard(_ theme: ThemeDefinition) -> some View {
let isActive = store.customization.theme == theme.id
return VStack(spacing: 6) {
RoundedRectangle(cornerRadius: 8)
.fill(theme.palette.bg)
.frame(height: 60)
.overlay(
Text("Aa")
.font(.system(size: 18, weight: .bold))
.foregroundColor(theme.palette.fg)
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isActive ? Color.green : Color.white.opacity(0.1), lineWidth: isActive ? 2 : 0.5)
)
Text(theme.name)
.font(.system(size: 11, weight: .medium))
.foregroundColor(.white.opacity(0.8))
HStack(spacing: 4) {
if isActive {
Text("Active")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.green)
} else {
Button("Apply") {
store.update { $0.theme = theme.id }
}
.buttonStyle(.plain)
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.white.opacity(0.6))
}
if !theme.isBuiltIn {
Button {
pluginManager.uninstall(type: "themes", id: theme.id)
} label: {
Image(systemName: "trash")
.font(.system(size: 9))
.foregroundColor(.red.opacity(0.6))
}
.buttonStyle(.plain)
}
}
}
.padding(8)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.05)))
}
// MARK: - Buddies
private var buddiesSection: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))], spacing: 12) {
ForEach(buddyRegistry.buddies) { buddy in
buddyCard(buddy)
}
}
}
private func buddyCard(_ buddy: BuddyDefinition) -> some View {
let isActive = store.customization.buddyId == buddy.id
return VStack(spacing: 6) {
Group {
if buddy.isBuiltIn {
PixelCharacterView(state: .idle)
} else {
PluginBuddyView(definition: buddy, state: .idle)
}
}
.frame(width: 52, height: 44)
.scaleEffect(0.9)
Text(buddy.name)
.font(.system(size: 11, weight: .medium))
.foregroundColor(.white.opacity(0.8))
HStack(spacing: 4) {
if isActive {
Text("Active")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.green)
} else {
Button("Apply") {
store.update { $0.buddyId = buddy.id }
}
.buttonStyle(.plain)
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.white.opacity(0.6))
}
if !buddy.isBuiltIn {
Button {
pluginManager.uninstall(type: "buddies", id: buddy.id)
} label: {
Image(systemName: "trash")
.font(.system(size: 9))
.foregroundColor(.red.opacity(0.6))
}
.buttonStyle(.plain)
}
}
}
.padding(8)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.05)))
}
// MARK: - Sounds
private var soundsSection: some View {
VStack(alignment: .leading, spacing: 8) {
let soundPlugins = pluginManager.installedPlugins.filter { $0.type == .sound }
if soundPlugins.isEmpty {
VStack(spacing: 8) {
Image(systemName: "music.note")
.font(.system(size: 28))
.foregroundColor(.white.opacity(0.2))
Text("No sound plugins installed")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.4))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 30)
} else {
ForEach(soundPlugins) { plugin in
soundRow(plugin)
}
}
}
}
private func soundRow(_ plugin: PluginManifest) -> some View {
HStack {
Image(systemName: "music.note")
.foregroundColor(.white.opacity(0.6))
Text(plugin.name)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white.opacity(0.8))
Spacer()
Button {
pluginManager.uninstall(type: "sounds", id: plugin.id)
} label: {
Image(systemName: "trash")
.font(.system(size: 10))
.foregroundColor(.red.opacity(0.6))
}
.buttonStyle(.plain)
}
.padding(8)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.05)))
}
// MARK: - Available plugins from registry
private func availableRow(_ entry: PluginDownloader.RegistryEntry) -> some View {
HStack {
Image(systemName: entry.type == .theme ? "paintpalette" :
entry.type == .buddy ? "figure.wave" : "music.note")
.foregroundColor(.white.opacity(0.5))
.frame(width: 16)
VStack(alignment: .leading, spacing: 2) {
Text(entry.name)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white.opacity(0.8))
if let desc = entry.description {
Text(desc)
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.4))
.lineLimit(1)
}
}
Spacer()
if entry.price > 0 {
Text("$\(String(format: "%.2f", Double(entry.price) / 100))")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.white.opacity(0.5))
}
if downloadingId == entry.id {
ProgressView()
.controlSize(.small)
.scaleEffect(0.7)
} else {
Button(entry.price > 0 ? "Buy" : "Install") {
Task {
downloadingId = entry.id
try? await downloader.download(entry)
downloadingId = nil
}
}
.buttonStyle(.plain)
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.green.opacity(0.8))
}
}
.padding(8)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.05)))
}
// MARK: - Install Sheet
private var installSheet: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Install Plugin")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
// URL install
VStack(alignment: .leading, spacing: 6) {
Text("From URL")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.white.opacity(0.6))
HStack(spacing: 8) {
TextField("https://github.com/.../plugin.json", text: $installURL)
.textFieldStyle(.plain)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.white.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 0.5)
)
Button {
Task { await installFromURL() }
} label: {
Text(isInstalling ? "..." : "Install")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.green)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(RoundedRectangle(cornerRadius: 6).fill(Color.green.opacity(0.15)))
}
.buttonStyle(.plain)
.disabled(installURL.isEmpty || isInstalling)
}
}
// Divider
HStack {
Rectangle().fill(Color.white.opacity(0.1)).frame(height: 1)
Text("or").font(.system(size: 10)).foregroundColor(.white.opacity(0.3))
Rectangle().fill(Color.white.opacity(0.1)).frame(height: 1)
}
// Local folder
Button {
installFromFolder()
} label: {
HStack {
Image(systemName: "folder.badge.plus")
.font(.system(size: 14))
Text("Choose plugin folder...")
.font(.system(size: 12))
}
.foregroundColor(.white.opacity(0.7))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.05)))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.white.opacity(0.1), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
// Status message
if let status = installStatus {
Text(status)
.font(.system(size: 10))
.foregroundColor(status.contains("") ? .green : .red.opacity(0.8))
}
}
.padding(16)
.frame(width: 380)
.background(RoundedRectangle(cornerRadius: 12).fill(Color(white: 0.12)))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color.white.opacity(0.1), lineWidth: 0.5)
)
}
// MARK: - Install Actions
private func installFromURL() async {
let urlStr = installURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: urlStr) else {
installStatus = "✗ Invalid URL"
return
}
isInstalling = true
installStatus = nil
do {
// Download plugin.json
let (data, _) = try await URLSession.shared.data(from: url)
let manifest = try JSONDecoder().decode(PluginManifest.self, from: data)
// Create temp dir and save
let tmpDir = FileManager.default.temporaryDirectory
.appendingPathComponent("codeisland-install-\(manifest.id)")
try? FileManager.default.removeItem(at: tmpDir)
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
try data.write(to: tmpDir.appendingPathComponent("plugin.json"))
// Determine type dir
let typeDir: String
switch manifest.type {
case .theme: typeDir = "themes"
case .buddy: typeDir = "buddies"
case .sound: typeDir = "sounds"
}
try PluginManager.shared.install(pluginDir: tmpDir, type: typeDir, id: manifest.id)
try? FileManager.default.removeItem(at: tmpDir)
installStatus = "✓ Installed \(manifest.name)"
installURL = ""
} catch {
installStatus = "\(error.localizedDescription)"
}
isInstalling = false
}
private func installFromFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.message = "Select a plugin folder containing plugin.json"
panel.prompt = "Install"
guard panel.runModal() == .OK, let url = panel.url else { return }
let pluginJsonURL = url.appendingPathComponent("plugin.json")
guard FileManager.default.fileExists(atPath: pluginJsonURL.path),
let data = try? Data(contentsOf: pluginJsonURL),
let manifest = try? JSONDecoder().decode(PluginManifest.self, from: data) else {
installStatus = "✗ No valid plugin.json found in folder"
return
}
let typeDir: String
switch manifest.type {
case .theme: typeDir = "themes"
case .buddy: typeDir = "buddies"
case .sound: typeDir = "sounds"
}
do {
try PluginManager.shared.install(pluginDir: url, type: typeDir, id: manifest.id)
installStatus = "✓ Installed \(manifest.name)"
} catch {
installStatus = "\(error.localizedDescription)"
}
}
// MARK: - Hint
private var pluginDirHint: some View {
Text("~/.config/codeisland/plugins/")
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.3))
}
}