Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
496 lines
25 KiB
Swift
496 lines
25 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import UniformTypeIdentifiers
|
|
import TVAnarchyCore
|
|
|
|
/// First-run / setup pane: shows which external dependencies are present (and the
|
|
/// Homebrew command to install the missing ones), and lets the user pick the
|
|
/// local player TVAnarchy plays into. Detection is read-only; installing is the
|
|
/// user's action (we surface + copy the command rather than running brew for them).
|
|
struct SetupView: View {
|
|
@Bindable var controller: PlayerController
|
|
@Bindable var library: LibraryController
|
|
|
|
@State private var deps: [Dependency] = []
|
|
@State private var loading = true
|
|
@State private var newTypeName = ""
|
|
@State private var newTypeAdult = false
|
|
@State private var showSkinImporter = false
|
|
@State private var skinMessage: String?
|
|
@Environment(\.winampSkin) private var winampSkin
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 22) {
|
|
appearanceSection
|
|
Divider()
|
|
#if ENABLE_ADULT
|
|
adultSection
|
|
Divider()
|
|
#endif
|
|
libraryTypesSection
|
|
Divider()
|
|
folderTypesSection
|
|
Divider()
|
|
playbackSection
|
|
Divider()
|
|
homeSection
|
|
Divider()
|
|
VPNSettingsView()
|
|
Divider()
|
|
MeshJoinView()
|
|
Divider()
|
|
librarySection
|
|
Divider()
|
|
localPlayerSection
|
|
Divider()
|
|
dependencySection
|
|
}
|
|
.padding(24)
|
|
}
|
|
.navigationTitle("Settings")
|
|
.task { await reload() }
|
|
}
|
|
|
|
// MARK: appearance
|
|
|
|
private var appearanceSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Appearance").font(.title3).bold()
|
|
Picker("Theme", selection: $library.appTheme) {
|
|
ForEach(AppTheme.allCases) { theme in
|
|
Text(theme.label).tag(theme)
|
|
}
|
|
}
|
|
.pickerStyle(.radioGroup)
|
|
.help("Standard dark UI or classic Winamp 2.x skin for the Player")
|
|
Text(library.appTheme.help)
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
if library.appTheme.usesWinampChrome {
|
|
HStack(spacing: 6) {
|
|
WinampTransportButton(symbol: "backward.fill", width: 24, height: 18) {}
|
|
WinampTransportButton(symbol: "play.fill", width: 24, height: 18) {}
|
|
WinampTransportButton(symbol: "forward.fill", width: 24, height: 18) {}
|
|
}
|
|
.themed(library.appTheme, skin: winampSkin.package)
|
|
.allowsHitTesting(false)
|
|
winampSkinSection
|
|
}
|
|
}
|
|
.themed(library.appTheme, skin: winampSkin.package)
|
|
.fileImporter(
|
|
isPresented: $showSkinImporter,
|
|
allowedContentTypes: [UTType(filenameExtension: "wsz") ?? .data],
|
|
allowsMultipleSelection: false
|
|
) { result in
|
|
switch result {
|
|
case .success(let urls):
|
|
guard let url = urls.first else { return }
|
|
importWinampSkin(from: url)
|
|
case .failure(let err):
|
|
skinMessage = err.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
private var winampSkinSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Winamp .wsz skin")
|
|
.font(.subheadline).bold()
|
|
if let name = library.winampSkinName, library.winampSkinId != nil {
|
|
Text("Active: \(name)")
|
|
.font(.caption)
|
|
} else {
|
|
Text("No imported skin — using built-in Winamp palette.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
if let err = skinMessage ?? winampSkin.lastError {
|
|
Text(err)
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
HStack(spacing: 10) {
|
|
Button("Import .wsz…") { showSkinImporter = true }
|
|
.help("Import a classic Winamp 2.x .wsz skin file (extracts sprites for Player chrome)")
|
|
Button("Use Base Skin") { installBaseSkin() }
|
|
.help("Install the bundled Base 2.91 Winamp skin")
|
|
if library.winampSkinId != nil {
|
|
Button("Clear Skin") { clearWinampSkin() }
|
|
.help("Revert to the built-in Winamp palette (remove imported skin)")
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
Text("Drop any classic Winamp 2.x `.wsz` file here — TVAnarchy extracts CBUTTONS, POSBAR, VOLUME, NUMBERS, and VISCOLOR for the Player chrome.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
|
|
private func importWinampSkin(from url: URL) {
|
|
skinMessage = nil
|
|
let access = url.startAccessingSecurityScopedResource()
|
|
defer { if access { url.stopAccessingSecurityScopedResource() } }
|
|
do {
|
|
let pkg = try winampSkin.install(wszURL: url)
|
|
applyWinampSkin(pkg)
|
|
skinMessage = "Installed “\(pkg.displayName)”."
|
|
} catch {
|
|
skinMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func installBaseSkin() {
|
|
skinMessage = nil
|
|
do {
|
|
let pkg = try winampSkin.installBundledBase()
|
|
applyWinampSkin(pkg)
|
|
skinMessage = "Installed bundled Base 2.91 skin."
|
|
} catch {
|
|
skinMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func applyWinampSkin(_ pkg: WinampSkinPackage) {
|
|
library.winampSkinId = pkg.id
|
|
library.winampSkinName = pkg.displayName
|
|
if !library.appTheme.usesWinampChrome {
|
|
library.appTheme = .winampClassic
|
|
}
|
|
}
|
|
|
|
private func clearWinampSkin() {
|
|
library.winampSkinId = nil
|
|
library.winampSkinName = nil
|
|
winampSkin.reload(id: nil, name: nil)
|
|
skinMessage = "Cleared imported skin."
|
|
}
|
|
|
|
// MARK: home
|
|
|
|
private var homeSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Home").font(.title3).bold()
|
|
Toggle("Continue Watching", isOn: $library.showContinueWatchingOnHome)
|
|
.toggleStyle(.switch).fixedSize()
|
|
.help("Resume rail on Home — saved across launches")
|
|
Text("Resume rail at the top of Home (and the adult-only Home when enabled).")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
Toggle("Recently Added", isOn: $library.showRecentlyAddedOnHome)
|
|
.toggleStyle(.switch).fixedSize()
|
|
.help("Newest additions rail on Home — saved across launches")
|
|
Text("Newest additions rail on Home. Independent of Continue Watching — turn either off without affecting the other.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// MARK: playback
|
|
|
|
private var playbackSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Playback").font(.title3).bold()
|
|
Picker("Shows playback mode", selection: $library.playbackMode) {
|
|
ForEach(PlaybackMode.allCases) { mode in
|
|
Text(mode.label).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
Text("Shows and movies on a local player: Stream fetches from the server on demand; Offline only plays cached files. Remote hosts always stream. Pick the device with the toolbar host chips — saved across launches.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
Toggle("Notify when downloads or offline cache finish", isOn: $library.notifyDownloads)
|
|
.toggleStyle(.switch).fixedSize()
|
|
.help("Notification Center alert and in-app banner for finished or stalled transfers")
|
|
|
|
// v1 BandwidthPolicy surface (options persisted; governor actuates onto transmission upload limits).
|
|
Text("Bandwidth tiers (you > friends > public)").font(.subheadline)
|
|
Toggle("Serve friends when idle", isOn: $library.serveFriendsWhenIdle)
|
|
.toggleStyle(.switch).fixedSize()
|
|
.help("When idle (not actively fetching), prioritize bandwidth for friends' streams and custody re-pins")
|
|
Toggle("Seed public when idle", isOn: $library.seedPublicWhenIdle)
|
|
.toggleStyle(.switch).fixedSize()
|
|
.help("When idle and friends toggled off, seed public swarms with leftover upload")
|
|
Text("Total upload (KB/s, 0=unmetered): \(library.totalUploadKBps ?? 0)")
|
|
.font(.caption)
|
|
Stepper("Cap", value: Binding(get: { Double(library.totalUploadKBps ?? 0) }, set: { library.totalUploadKBps = Int($0) }), in: 0...20000, step: 100)
|
|
.fixedSize()
|
|
.help("Global upload rate cap passed to governor for bandwidth policy (0 = unlimited)")
|
|
Text("Notification Center alert + in-app banner when a torrent finishes or stalls, or when offline warmup completes.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
Stepper("Skip intro: \(library.skipIntroSeconds == 0 ? "off" : "\(library.skipIntroSeconds)s")",
|
|
value: $library.skipIntroSeconds, in: 0...180, step: 15)
|
|
.fixedSize()
|
|
.help("Skip Intro on the Player page seeks to this offset — 0 hides the button")
|
|
Text("Skip Intro on the Player page seeks to this offset. Set to 0 to hide the button.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// MARK: adult
|
|
|
|
#if ENABLE_ADULT
|
|
/// Adult-content options. The master switch is the discreet eye icon at the
|
|
/// bottom of the sidebar (not duplicated here, to keep one source of truth);
|
|
/// these two sub-options only apply while it's on and are disabled otherwise.
|
|
private var adultSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Adult content").font(.title3).bold()
|
|
Text(library.pornFeature
|
|
? "Enabled. Toggle it off with the eye icon at the bottom of the sidebar."
|
|
: "Hidden. Click the eye icon at the bottom of the sidebar to reveal the Adult tab.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Toggle("Blend adult content into Home", isOn: $library.surfaceAdultOnHome)
|
|
.toggleStyle(.switch).fixedSize().disabled(!library.pornFeature)
|
|
.help("Show adult category and items on the main Home — independent of the Library browse toggle")
|
|
Text("Show the porn category and adult items in the main Home's Continue Watching / Recently Added rails.")
|
|
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
|
|
Toggle("Adult tab as its own Home", isOn: $library.switchToAdultOnlyHome)
|
|
.toggleStyle(.switch).fixedSize().disabled(!library.pornFeature)
|
|
.help("Adult tab gets Continue Watching and Recently Added rails above collections")
|
|
Text("Make the Adult tab a full adult-only landing — Continue Watching and Recently Added rails above the collections — instead of just the collections browser.")
|
|
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
|
|
Picker("Adult playback mode", selection: $library.adultPlaybackMode) {
|
|
ForEach(PlaybackMode.allCases) { mode in
|
|
Text(mode.label).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented).disabled(!library.pornFeature)
|
|
.help("Playback mode (stream vs offline cache) only for adult content — independent of the main shows mode")
|
|
Text("Independent of shows sourcing — applies when adult content plays on a local player. Pick the device with the host chips in the toolbar.")
|
|
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
|
|
Stepper("Queue \(library.adultQueueCount) clips per collection tap",
|
|
value: $library.adultQueueCount, in: 5...100, step: 5)
|
|
.fixedSize().disabled(!library.pornFeature)
|
|
.help("Number of preview clips to queue when tapping an Adult collection")
|
|
Text("How many fresh clips each Adult collection tap queues.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: library type catalog (editable / expandable)
|
|
|
|
/// Edit the catalog of library types a folder can be: rename, toggle adult,
|
|
/// add custom types, remove unused ones. The default catalog ships seeded;
|
|
/// "Reset" restores it.
|
|
private var libraryTypesSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("Library types").font(.title3).bold()
|
|
Spacer()
|
|
Button("Reset to defaults") { library.resetLibraryTypes() }.controlSize(.small)
|
|
.help("Restore the built-in type catalog (TV, Anime, Movies, Porn, …)")
|
|
}
|
|
Text("The set of types a folder can be assigned. Adult types (Porn by default) are hidden/gated everywhere.")
|
|
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
|
|
ForEach(library.libraryTypes) { t in
|
|
HStack(spacing: 8) {
|
|
TextField("Label", text: Binding(
|
|
get: { t.label },
|
|
set: { library.updateLibraryType(t.id, label: $0, adult: t.adult) }
|
|
))
|
|
.textFieldStyle(.roundedBorder).frame(width: 150)
|
|
Text(t.id).font(.caption2.monospaced()).foregroundStyle(.tertiary).lineLimit(1)
|
|
Spacer()
|
|
Toggle("Adult", isOn: Binding(
|
|
get: { t.adult },
|
|
set: { library.updateLibraryType(t.id, label: t.label, adult: $0) }
|
|
)).toggleStyle(.switch).controlSize(.mini).fixedSize()
|
|
.help("Adult types are gated behind the sidebar eye icon")
|
|
Button(role: .destructive) { library.removeLibraryType(t.id) } label: {
|
|
Image(systemName: "trash")
|
|
}.buttonStyle(.borderless).help("Remove this type")
|
|
}
|
|
}
|
|
HStack(spacing: 8) {
|
|
TextField("Add a type…", text: $newTypeName).textFieldStyle(.roundedBorder).frame(width: 150)
|
|
Toggle("Adult", isOn: $newTypeAdult).toggleStyle(.switch).controlSize(.mini).fixedSize()
|
|
.help("Mark the new type as adult content")
|
|
Button("Add") {
|
|
library.addLibraryType(name: newTypeName, adult: newTypeAdult)
|
|
newTypeName = ""; newTypeAdult = false
|
|
}
|
|
.disabled(newTypeName.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
.help("Add a new custom library type (can be marked adult)")
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
}
|
|
|
|
// MARK: library folder types
|
|
|
|
/// Assign each top-level media folder a library type. Re-typing folds a folder
|
|
/// into another type's rails (e.g. an oddly-named folder → Movies); setting it
|
|
/// to Porn marks it adult — all without renaming anything on disk.
|
|
private var folderTypesSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Library folder types").font(.title3).bold()
|
|
Text("Each top-level media folder is a library type. Change a folder's type to group it with that type everywhere; set it to Porn to treat it as adult. Defaults to the folder's own name — no on-disk renaming.")
|
|
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
|
|
if library.presentFolders.isEmpty {
|
|
Text("No folders yet — rebuild the index once the media mount is reachable.")
|
|
.font(.caption).foregroundStyle(.tertiary)
|
|
} else {
|
|
ForEach(library.presentFolders, id: \.self) { folder in
|
|
HStack {
|
|
Text(folder).font(.callout).lineLimit(1)
|
|
Spacer()
|
|
Picker("", selection: Binding(
|
|
get: { library.type(of: folder) },
|
|
set: { library.setFolderType(folder, $0) }
|
|
)) {
|
|
ForEach(library.libraryTypes) { t in Text(t.label).tag(t.id) }
|
|
// Folders whose name isn't itself a configured type get
|
|
// an "as-is" option so the picker has a matching tag for
|
|
// the identity (unmapped) value.
|
|
if library.libraryTypes.first(where: { $0.id == folder }) == nil {
|
|
Divider()
|
|
Text("As-is (\(folder))").tag(folder)
|
|
}
|
|
}
|
|
.labelsHidden().fixedSize()
|
|
.help("Classify this folder — saved in settings.json; Porn marks it adult without renaming on disk")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func reload() async {
|
|
loading = true
|
|
deps = await DepsService.detect()
|
|
loading = false
|
|
}
|
|
|
|
// MARK: library index
|
|
|
|
private var librarySection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Library index").font(.title3).bold()
|
|
Text("black builds a fast index of the media library out-of-band; TVAnarchy reads it instantly instead of walking the network share (minutes over NFS). It refreshes automatically when a download finishes — rebuild manually if you added media on black directly.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
Button { library.rebuildIndex() } label: {
|
|
Label(library.rebuildingIndex ? "Rebuilding on black…" : "Rebuild full index (overscan)",
|
|
systemImage: "arrow.triangle.2.circlepath")
|
|
}
|
|
.disabled(library.rebuildingIndex)
|
|
.help("Walk the media library on black and refresh the cached index TVAnarchy reads")
|
|
if library.rebuildingIndex {
|
|
ScanningBanner(progress: library.scanProgress, total: library.scanTotal, label: "Indexing on black")
|
|
}
|
|
if let last = library.lastRefresh {
|
|
Text("\(library.shows.count) items · loaded \(last.formatted(date: .abbreviated, time: .shortened)) · source: \(library.source)")
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
}
|
|
Divider().padding(.vertical, 2)
|
|
Toggle("Hover previews", isOn: $library.hoverPreviews)
|
|
.toggleStyle(.switch).fixedSize()
|
|
.help("Muted preview clip on poster hover — first hover per show builds on black via ffmpeg")
|
|
Text("Play a short muted clip (seeked past the intro) when you hover a poster. Clips are built on black with ffmpeg the first time you hover, then cached — so the first play of each show lags a few seconds.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
Divider().padding(.vertical, 2)
|
|
Toggle("Combine split/duplicate shows", isOn: $library.combineSplitShows)
|
|
.toggleStyle(.switch).fixedSize()
|
|
.help("Merge duplicate library folders of one show — decisions cached on disk")
|
|
Text("Merge separate folders/releases of one show into a single entry (e.g. Dandadan's duplicates). A fast deterministic matcher handles it; decisions are cached.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
Toggle("Use the local LLM for hard cases", isOn: $library.useLLMGrouper)
|
|
.toggleStyle(.switch).fixedSize()
|
|
.disabled(!library.combineSplitShows)
|
|
.help("Optional MLX model for ambiguous same-year clusters — off uses the deterministic matcher")
|
|
Text("For ambiguous clusters only, ask the on-device MLX model (a separate download) to judge same-work vs different-work. Off by default.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// MARK: local player
|
|
|
|
private var localPlayerSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Local player").font(.title3).bold()
|
|
Text("Which app plays video on this Mac. Remote hosts (black) are unaffected.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
Picker("Local player", selection: Binding(
|
|
get: { controller.localPlayerKind ?? .quicktime },
|
|
set: { controller.setLocalPlayer($0) }
|
|
)) {
|
|
Text("QuickTime").tag(HostKind.quicktime)
|
|
Text("VLC").tag(HostKind.vlc)
|
|
}
|
|
.pickerStyle(.segmented).fixedSize()
|
|
.help("Which app plays video on this Mac — saved in devices.json")
|
|
Text(controller.localPlayerKind == .vlc
|
|
? "VLC: full control (play/seek/volume) over its HTTP interface. Needs VLC installed + its web interface enabled."
|
|
: "QuickTime: built into macOS, no install. Play/pause, seek and volume work; no playlist or quality switching.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// MARK: dependencies
|
|
|
|
private var dependencySection: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text("Dependencies").font(.title3).bold()
|
|
if loading { ProgressView().controlSize(.small) }
|
|
Spacer()
|
|
Button { Task { await reload() } } label: { Image(systemName: "arrow.clockwise") }
|
|
.disabled(loading)
|
|
.help("Re-check which dependencies are installed")
|
|
}
|
|
if !loading {
|
|
Text(readiness).font(.caption).foregroundStyle(missingRequired ? .orange : .green)
|
|
}
|
|
ForEach(deps) { dep in depRow(dep) }
|
|
if missingAny {
|
|
Text("Install everything missing:").font(.caption).foregroundStyle(.secondary).padding(.top, 4)
|
|
copyableCommand(installAllCommand)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func depRow(_ dep: Dependency) -> some View {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Image(systemName: dep.found ? "checkmark.circle.fill" : (dep.need == .required ? "xmark.circle.fill" : "circle.dashed"))
|
|
.foregroundStyle(dep.found ? .green : (dep.need == .required ? .red : .secondary))
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
Text(dep.name).font(.callout).bold()
|
|
if dep.need == .optional { Text("optional").font(.caption2).foregroundStyle(.secondary) }
|
|
}
|
|
Text(dep.purpose).font(.caption).foregroundStyle(.secondary)
|
|
Text(dep.detail).font(.caption2.monospaced()).foregroundStyle(dep.found ? Color.secondary : Color.orange)
|
|
if !dep.found {
|
|
copyableCommand(dep.installHint)
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 3)
|
|
}
|
|
|
|
private func copyableCommand(_ cmd: String) -> some View {
|
|
HStack(spacing: 8) {
|
|
Text(cmd).font(.caption.monospaced()).textSelection(.enabled)
|
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
|
|
Button { copy(cmd) } label: { Image(systemName: "doc.on.doc") }
|
|
.buttonStyle(.borderless).help("Copy")
|
|
}
|
|
}
|
|
|
|
private func copy(_ s: String) {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(s, forType: .string)
|
|
}
|
|
|
|
private var missingRequired: Bool { deps.contains { !$0.found && $0.need == .required } }
|
|
private var missingAny: Bool { deps.contains { !$0.found } }
|
|
private var readiness: String {
|
|
missingRequired ? "Some required tools are missing — install them below."
|
|
: "All required tools present."
|
|
}
|
|
private var installAllCommand: String {
|
|
let cmds = deps.filter { !$0.found && $0.installHint.hasPrefix("brew") }.map(\.installHint)
|
|
return cmds.isEmpty ? "(nothing to install)" : Array(Set(cmds)).sorted().joined(separator: " && ")
|
|
}
|
|
}
|