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>
158 lines
No EOL
6.9 KiB
Swift
158 lines
No EOL
6.9 KiB
Swift
import Foundation
|
|
import CryptoKit
|
|
|
|
public struct WinampRGB: Sendable, Equatable {
|
|
public let r: Int
|
|
public let g: Int
|
|
public let b: Int
|
|
}
|
|
|
|
public struct WinampPleditStyle: Sendable, Equatable {
|
|
public var normal: WinampRGB?
|
|
public var current: WinampRGB?
|
|
public var normalBG: WinampRGB?
|
|
public var selectedBG: WinampRGB?
|
|
public var font: String?
|
|
}
|
|
|
|
/// Installed `.wsz` skin — extracted cache + parsed config text files.
|
|
public struct WinampSkinPackage: Sendable, Equatable, Identifiable {
|
|
public let id: String
|
|
public let displayName: String
|
|
public let cacheDirectory: URL
|
|
public let visColors: [WinampRGB]
|
|
public let pledit: WinampPleditStyle
|
|
public let availableSheets: Set<String>
|
|
|
|
public var isPlayerCompatible: Bool {
|
|
availableSheets.contains("CBUTTONS")
|
|
&& availableSheets.contains("POSBAR")
|
|
&& (availableSheets.contains("MAIN") || availableSheets.contains("TITLEBAR"))
|
|
}
|
|
}
|
|
|
|
public enum WinampSkinLoader {
|
|
private static let requiredSheets = ["CBUTTONS", "POSBAR", "MAIN"]
|
|
|
|
public static func skinsRoot() -> URL {
|
|
let base: URL
|
|
if let dir = ProcessInfo.processInfo.environment["TV_ANARCHY_STATE_DIR"], !dir.isEmpty {
|
|
base = URL(fileURLWithPath: dir, isDirectory: true)
|
|
} else {
|
|
base = FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".local/state/tv-anarchy", isDirectory: true)
|
|
}
|
|
return base.appendingPathComponent("skins", isDirectory: true)
|
|
}
|
|
|
|
/// Extract a `.wsz` (zip) skin into the cache and return metadata.
|
|
public static func install(wszURL: URL, displayName: String? = nil) throws -> WinampSkinPackage {
|
|
let data = try Data(contentsOf: wszURL)
|
|
let id = SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined()
|
|
let dest = skinsRoot().appendingPathComponent(id, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: skinsRoot(), withIntermediateDirectories: true)
|
|
if FileManager.default.fileExists(atPath: dest.path) {
|
|
try FileManager.default.removeItem(at: dest)
|
|
}
|
|
try FileManager.default.createDirectory(at: dest, withIntermediateDirectories: true)
|
|
let archive = dest.appendingPathComponent("source.wsz")
|
|
try data.write(to: archive, options: .atomic)
|
|
let unzip = ProcessRunner.run("/usr/bin/unzip", ["-oq", archive.path, "-d", dest.path])
|
|
guard unzip.ok else {
|
|
throw WinampSkinError.unzipFailed(unzip.stderr.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
}
|
|
return try loadPackage(id: id, displayName: displayName ?? wszURL.deletingPathExtension().lastPathComponent,
|
|
cacheDirectory: dest)
|
|
}
|
|
|
|
public static func loadPackage(id: String, displayName: String, cacheDirectory: URL) throws -> WinampSkinPackage {
|
|
let entries = (try? FileManager.default.contentsOfDirectory(atPath: cacheDirectory.path)) ?? []
|
|
let sheets = Set(entries.map { $0.uppercased().replacingOccurrences(of: ".BMP", with: "") }
|
|
.filter { entries.contains("\($0).BMP") || entries.contains("\($0).bmp") })
|
|
let bmpNames = Set(entries.filter { $0.lowercased().hasSuffix(".bmp") }
|
|
.map { ($0 as NSString).deletingPathExtension.uppercased() })
|
|
let vis = parseVisColors(readText(named: "VISCOLOR", in: cacheDirectory))
|
|
let pledit = parsePledit(readText(named: "PLEDIT", in: cacheDirectory))
|
|
let pkg = WinampSkinPackage(
|
|
id: id,
|
|
displayName: displayName,
|
|
cacheDirectory: cacheDirectory,
|
|
visColors: vis,
|
|
pledit: pledit,
|
|
availableSheets: bmpNames
|
|
)
|
|
guard pkg.availableSheets.contains("CBUTTONS"), pkg.availableSheets.contains("POSBAR") else {
|
|
throw WinampSkinError.missingSheets(required: requiredSheets, found: Array(bmpNames).sorted())
|
|
}
|
|
return pkg
|
|
}
|
|
|
|
public static func loadInstalled(id: String, displayName: String) -> WinampSkinPackage? {
|
|
let dir = skinsRoot().appendingPathComponent(id, isDirectory: true)
|
|
guard FileManager.default.fileExists(atPath: dir.path) else { return nil }
|
|
return try? loadPackage(id: id, displayName: displayName, cacheDirectory: dir)
|
|
}
|
|
|
|
// MARK: parsers
|
|
|
|
static func parseVisColors(_ text: String?) -> [WinampRGB] {
|
|
guard let text else { return [] }
|
|
return text.split(whereSeparator: \.isNewline).compactMap { line in
|
|
let trimmed = line.split(separator: "/").first.map(String.init) ?? String(line)
|
|
let parts = trimmed.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
|
guard parts.count >= 3,
|
|
let r = Int(parts[0]), let g = Int(parts[1]), let b = Int(parts[2]) else { return nil }
|
|
return WinampRGB(r: r, g: g, b: b)
|
|
}
|
|
}
|
|
|
|
static func parsePledit(_ text: String?) -> WinampPleditStyle {
|
|
guard let text else { return WinampPleditStyle() }
|
|
var style = WinampPleditStyle()
|
|
for raw in text.split(whereSeparator: \.isNewline) {
|
|
let line = raw.trimmingCharacters(in: .whitespaces)
|
|
guard let eq = line.firstIndex(of: "=") else { continue }
|
|
let key = line[..<eq].trimmingCharacters(in: .whitespaces)
|
|
var val = line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces)
|
|
switch key.lowercased() {
|
|
case "normal": style.normal = parseHexRGB(val)
|
|
case "current": style.current = parseHexRGB(val)
|
|
case "normalbg": style.normalBG = parseHexRGB(val)
|
|
case "selectedbg": style.selectedBG = parseHexRGB(val)
|
|
case "font": style.font = val
|
|
default: break
|
|
}
|
|
}
|
|
return style
|
|
}
|
|
|
|
static func parseHexRGB(_ hex: String) -> WinampRGB? {
|
|
var s = hex
|
|
if s.hasPrefix("#") { s.removeFirst() }
|
|
guard s.count == 6, let n = Int(s, radix: 16) else { return nil }
|
|
return WinampRGB(r: (n >> 16) & 0xFF, g: (n >> 8) & 0xFF, b: n & 0xFF)
|
|
}
|
|
|
|
static func readText(named base: String, in dir: URL) -> String? {
|
|
for ext in ["txt", "TXT"] {
|
|
let url = dir.appendingPathComponent("\(base).\(ext)")
|
|
if let data = try? Data(contentsOf: url), let s = String(data: data, encoding: .utf8) {
|
|
return s
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public enum WinampSkinError: Error, LocalizedError {
|
|
case unzipFailed(String)
|
|
case missingSheets(required: [String], found: [String])
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .unzipFailed(let msg): return "Could not extract .wsz: \(msg)"
|
|
case .missingSheets(let req, let found):
|
|
return "Skin missing required bitmaps \(req.joined(separator: ", ")). Found: \(found.joined(separator: ", "))"
|
|
}
|
|
}
|
|
} |