CloverBootloader/CloverApp/Clover/ThemeManager/ThemeManager.swift
vectorsigma72 99e9fc5d89 Clover.app v1.15 with themes manager
Themes can be downloaded without git. The parser allows users to chose the Github user and the repository name, and so load themes from forks or any repo that has compatible directories structure (the repository must contains only themes, at first level).
2020-02-11 15:46:53 +01:00

478 lines
15 KiB
Swift

//
// ThemeManager.swift
// ThemeManager
//
// Created by vector sigma on 04/01/2020.
// Copyright © 2020 vectorsigma. All rights reserved.
//
import Cocoa
let kThemeUserKey = "themeUser"
let kThemeRepoKey = "themeRepo"
let kDefaultThemeUser = "CloverHackyColor" // CloverHackyColor
let kDefaultThemeRepo = "CloverThemes"
enum ThemeDownload {
case indexOnly
case thumbnail
case complete
}
let kCloverThemeAttributeKey = "org.cloverTheme.sha"
class ThemeManager: NSObject, URLSessionDataDelegate {
var delegate : ThemeManagerVC?
private var user : String
private var repo : String
var basePath : String
private var urlBaseStr : String
private var themeManagerIndexDir : String
let userAgent = "Clover"
required init(user: String, repo: String,
basePath: String,
indexDir : String,
delegate: ThemeManagerVC?) {
self.user = user
self.repo = repo
self.basePath = basePath
self.urlBaseStr = "https://api.github.com/repos/\(user)/\(repo)/git/trees/master?recursive=1"
self.themeManagerIndexDir = indexDir
self.delegate = delegate
if !fm.fileExists(atPath: self.themeManagerIndexDir) {
try? fm.createDirectory(atPath: self.themeManagerIndexDir,
withIntermediateDirectories: true,
attributes: nil)
}
}
public func getIndexedThemes() -> [String] {
var themes = [String]()
if self.getSha() != nil {
let themesIndexPath = self.themeManagerIndexDir.addPath("Themes")
if let files = try? fm.contentsOfDirectory(atPath: themesIndexPath) {
for f in files {
let theme = f.deletingFileExtension
let ext = f.fileExtension
if ext == "plist" {
themes.append(theme)
}
}
}
}
return themes.sorted()
}
public func getThemes(completion: @escaping ([String]) -> ()) {
var themes = [String]()
let themesIndexPath = self.themeManagerIndexDir.addPath("Themes")
self.getInfo(urlString: urlBaseStr) { (success) in
do {
let files = try fm.contentsOfDirectory(atPath: themesIndexPath)
for f in files {
let theme = f.deletingFileExtension
let ext = f.fileExtension
if ext == "plist" {
themes.append(theme)
}
}
completion(themes)
} catch {
completion(themes)
}
}
}
/// Return the sha string only if the sha exist, and only if Themes directory exists.
func getSha() -> String? {
var sha : String? = nil
let themesPath = "\(self.themeManagerIndexDir)/Themes"
let shaPath = "\(self.themeManagerIndexDir)/sha"
if fm.fileExists(atPath: themesPath) && fm.fileExists(atPath: shaPath) {
if let data = try? Data(contentsOf: URL(fileURLWithPath: shaPath)) {
sha = String(data: data, encoding: .utf8)
if ((sha != nil) && (sha!.count < 40)) {
sha = nil
}
}
}
return sha
}
private func normalize(_ url: String) -> String {
if (url.range(of: " ") != nil) {
return url.replacingOccurrences(of: " ", with: "%20")
}
return url
}
private func downloadloadFile(at url: String, dst: String,
completion: @escaping (Bool) -> ()) {
if let validURL = URL(string: self.normalize(url)) {
let upperDir = dst.deletingLastPath
if !fm.fileExists(atPath: upperDir) {
do {
try fm.createDirectory(atPath: upperDir,
withIntermediateDirectories: true,
attributes: nil)
} catch {
print("DF1, \(error)")
completion(false)
return
}
}
var request = URLRequest(url: validURL)
request.httpMethod = "GET"
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
let config = URLSessionConfiguration.ephemeral
let session = URLSession(configuration: config)
let task = session.dataTask(with: request, completionHandler: {(d, r, e) in
if (e != nil) {
print("DF2, \(e!)")
completion(false)
return
}
guard let data = d else {
print("DF3, Error: no datas.")
completion(false)
return
}
do {
try data.write(to: URL(fileURLWithPath: dst))
completion(true)
} catch {
print("DF4, \(error)")
completion(false)
}
})
task.resume()
} else {
print("DF5, Error: invalid url '\(url)'.")
completion(false)
}
}
private func getInfo(urlString: String, completion: @escaping (Bool) -> ()) {
if let url = URL(string: self.normalize(urlString)) {
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
let config = URLSessionConfiguration.ephemeral
let session = URLSession(configuration: config)
let task = session.dataTask(with: request, completionHandler: {(d, r, e) in
if (e != nil) {
print("GI1, \(e!)")
completion(false)
return
}
guard let data = d else {
print("GI2, no data.")
completion(false)
return
}
guard let utf8 = String(decoding: data, as: UTF8.self).data(using: .utf8) else {
print("GI3, data is not utf8.")
completion(false)
return
}
do {
let json = try JSONSerialization.jsonObject(with: utf8, options: .mutableContainers)
if let jdict = json as? [String: Any] {
guard let truncated = jdict["truncated"] as? Bool else {
print("GI4, Error: 'truncated' key not found")
completion(false)
return
}
if truncated == true {
print("GI4, Error: json has truncated list.")
completion(false)
return
}
guard let sha = jdict["sha"] as? String else {
print("GI4, Error: 'sha' key not found")
completion(false)
return
}
if sha == self.getSha() {
// we're done because we already have all the files needed
completion(false)
return
}
guard let tree = jdict["tree"] as? [[String: Any]] else {
print("GI4, Error: 'tree' key not found, or not an array.")
completion(false)
return
}
let shaPath = "\(self.themeManagerIndexDir)/\(sha)"
do {
if !fm.fileExists(atPath: shaPath) {
try fm.createDirectory(atPath: shaPath, withIntermediateDirectories: true, attributes: nil)
}
} catch {
print("GI5, Error: can't write sha commit.")
completion(false)
return
}
for obj in tree {
/*
"path": ".gitignore",
"mode": "100644",
"type": "blob",
"sha": "e43b0f988953ae3a84b00331d0ccf5f7d51cb3cf",
"size": 10,
"url": "https://api.github.com/repos/vectorsigma72/CloverThemes/git/blobs/e43b0f988953ae3a84b00331d0ccf5f7d51cb3cf"
*/
guard let type = obj["type"] as? String else {
print("GI6, Error: 'type' key not found")
completion(false)
break
}
if let path = obj["path"] as? String {
if !path.hasPrefix(".") && type == "blob" { // .gitignore, .DS_Store
let themeName = path.components(separatedBy: "/")[0]
let plistPath = "\(self.themeManagerIndexDir)/\(sha)/\(themeName).plist"
let theme = NSMutableArray(contentsOfFile: plistPath) ?? NSMutableArray()
if !theme.contains(path) {
theme.add(path)
}
if !theme.write(toFile: plistPath, atomically: false) {
print("GI7, Error: 'path' key not found.")
completion(false)
break
}
}
} else {
print("GI8, Error: 'path' key not found.")
completion(false)
break
}
}
// move temp files in standard position
do {
try sha.write(toFile: "\(self.themeManagerIndexDir)/sha", atomically: true, encoding: .utf8)
if fm.fileExists(atPath: "\(self.themeManagerIndexDir)/Themes") {
try fm.removeItem(atPath: "\(self.themeManagerIndexDir)/Themes")
}
try fm.moveItem(atPath: shaPath, toPath: "\(self.themeManagerIndexDir)/Themes")
self.removeOld(new: sha)
completion(true)
} catch {
print("GI9, \(error)")
completion(false)
return
}
//remove old themes (old sha)
} else {
print("GI10, json is not a dictionary (API change?).")
completion(false)
}
} catch {
print("GI11, \(error)")
completion(false)
}
})
task.resume()
} else {
print("GI12, \(urlString) is invalid.")
completion(false)
}
}
/// Remove old thumbnails from old sha commits
private func removeOld(new sha: String) {
do {
let contents : [String] = try fm.contentsOfDirectory(atPath: self.themeManagerIndexDir)
for c in contents {
if c != "sha" && c != "Themes" && c != sha {
try fm.removeItem(atPath: "\(self.themeManagerIndexDir)/\(c)")
}
}
} catch { }
}
/// Return the path for a given theme, if the download succeded
public func download(theme: String, down: ThemeDownload, completion: @escaping (String?) -> ()) {
if let sha = getSha() {
let shaPath = self.basePath.addPath(sha)
let themeDest = (down == .complete)
? self.themeManagerIndexDir.addPath("Downloads").addPath(theme)
: shaPath.addPath(theme)
if (down != .complete) && fm.fileExists(atPath: themeDest) {
completion(themeDest)
} else {
if !fm.fileExists(atPath: themeDest) {
do {
try fm.createDirectory(atPath: themeDest,
withIntermediateDirectories: true,
attributes: nil)
} catch {}
}
let plistPath = "\(themeManagerIndexDir)/Themes/\(theme).plist"
if let files = NSArray(contentsOfFile: plistPath) as? [String] {
let fc = files.count
if fc > 0 {
var broken = false
let dg = DispatchGroup()
for i in 0..<fc {
dg.enter()
if broken {
dg.leave()
break
} else {
let file = files[i]
// build the url
let furl = "https://github.com/\(self.user)/\(self.repo)/raw/master/\(file)"
if down == .thumbnail {
if file != theme.addPath("screenshot.png")
&& file != theme.addPath("theme.svg")
&& file != theme.addPath("theme.plist") {
dg.leave()
continue
}
}
let filedest = (down == .complete)
? themeDest.deletingLastPath.addPath(file)
: shaPath.addPath(file)
if !fm.fileExists(atPath: filedest) {
self.downloadloadFile(at: self.normalize(furl), dst: filedest) { (success) in
broken = (success == false)
dg.leave()
}
} else {
dg.leave()
}
}
}
dg.notify(queue: .main) {
if broken {
completion(nil)
} else {
completion(themeDest)
}
}
} else {
completion(nil)
}
} else {
completion(nil)
}
}
} else {
completion(nil)
}
}
public func getImageUrl(for theme: String, completion: @escaping (String?) -> ()) {
/*
We are going to load an image in a web view,
so no matter if the url will be a file on the local
filesystem (downloaded theme) or just on the online repository.
*/
if let sha = self.getSha() {
let localTheme = "\(basePath)/\(sha)/\(theme)"
let png = "\(localTheme)/screenshot.png"
let svg = "\(localTheme)/theme.svg"
if fm.fileExists(atPath: png) {
completion(png)
return
} else if fm.fileExists(atPath: svg) {
completion(svg)
return
}
}
// theme not found?? Downloading...
self.download(theme: theme, down: .thumbnail) { (path) in
if let localTheme = path {
let png = "\(localTheme)/screenshot.png"
let svg = "\(localTheme)/theme.svg"
if fm.fileExists(atPath: png) {
completion(png)
} else if fm.fileExists(atPath: svg) {
completion(svg)
}
} else {
completion(nil)
}
}
}
public func signTheme(at path: String) {
if let sha = getSha() {
let fileURL = URL(fileURLWithPath: path)
let data = sha.data(using: .utf8)
// remove all attributes
do {
let list = try fileURL.listExtendedAttributes()
for attr in list {
try fileURL.removeExtendedAttribute(forName: attr)
}
} catch {
print(error.localizedDescription)
}
// set attribute
do {
try fileURL.setExtendedAttribute(data: data!, forName: kCloverThemeAttributeKey)
/* nine test
let adata = try fileURL.extendedAttribute(forName: attr)
print(String(data: adata, encoding: .utf8)!)
*/
} catch let error {
print(error.localizedDescription)
}
}
}
public func exist(theme: String) -> Bool {
return fm.fileExists(atPath: "\(self.themeManagerIndexDir)/Themes/\(theme).plist")
}
}