// // 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" final 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 : [String] = try? fm.contentsOfDirectory(atPath: themesIndexPath) { for f in files { let theme : String = f.deletingFileExtension let ext : String = f.fileExtension if ext == "plist" { themes.append(theme) } } } } return themes.sorted() } public func getThemes(completion: @escaping ([String]) -> ()) { var themes : [String] = [String]() let themesIndexPath : String = self.themeManagerIndexDir.addPath("Themes") self.getInfo(urlString: urlBaseStr) { (success) in do { let files : [String] = try fm.contentsOfDirectory(atPath: themesIndexPath) for f in files { let theme : String = f.deletingFileExtension let ext : String = 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 : String = "\(self.themeManagerIndexDir)/Themes" let shaPath : String = "\(self.themeManagerIndexDir)/sha" if fm.fileExists(atPath: themesPath) && fm.fileExists(atPath: shaPath) { if let data : 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 = URL(string: self.normalize(url)) { let upperDir : String = 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 = 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 = 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 : String = "\(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 : String = path.components(separatedBy: "/")[0] let plistPath : String = "\(self.themeManagerIndexDir)/\(sha)/\(themeName).plist" let theme : NSMutableArray = 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 { } } private func thumbnailExist(at path: String) -> Bool { if fm.fileExists(atPath: path.addPath("theme.svg")) { return true } else { if fm.fileExists(atPath: path.addPath("theme.plist")) && fm.fileExists(atPath: path.addPath("screenshot.png")){ return true } } return false } /// Return the path for a given theme, if the download succeded public func download(theme: String, down: ThemeDownload, completion: @escaping (String?) -> ()) { if let sha = self.getSha() { let shaPath : String = self.basePath.addPath(sha) let themeDest : String = (down == .complete) ? self.themeManagerIndexDir.addPath("Downloads").addPath(theme) : shaPath.addPath(theme) if (down != .complete) && self.thumbnailExist(at: themeDest) { completion(themeDest) } else { if !fm.fileExists(atPath: themeDest) { do { try fm.createDirectory(atPath: themeDest, withIntermediateDirectories: true, attributes: nil) } catch {} } let plistPath : String = "\(themeManagerIndexDir)/Themes/\(theme).plist" if let files : [String] = NSArray(contentsOfFile: plistPath) as? [String] { let fc : Int = files.count if fc > 0 { var broken : Bool = false let dg = DispatchGroup() for i in 0.. ()) { /* 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 : String = self.getSha() { let localTheme : String = "\(basePath)/\(sha)/\(theme)" let png : String = "\(localTheme)/screenshot.png" let svg : String = "\(localTheme)/theme.svg" if fm.fileExists(atPath: png) { completion(png) return } else if fm.fileExists(atPath: svg) { completion(svg) return } } print("downloading thumbnail for \(theme)") // theme not found?? Downloading... self.download(theme: theme, down: .thumbnail) { (path) in if let localTheme : String = path { let png : String = "\(localTheme)/screenshot.png" let svg : String = "\(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 : String = self.getSha() { let fileURL : URL = URL(fileURLWithPath: path) let data : 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") } public func optimizeTheme(at path: String, err: inout Error?){ let enumerator = fm.enumerator(atPath: path) while let file = enumerator?.nextObject() as? String { let fullPath = path.addPath(file) if file.fileExtension == "png" || file.fileExtension == "icns" { if let data = try? Data(contentsOf: URL(fileURLWithPath: fullPath)) { if data[0] != 0x89 || data[0] != 0x50 || data[0] != 0x4E || data[0] != 0x47 { // convert it to png if let png = NSBitmapImageRep(data: data)?.png { do { try png.write(to: URL(fileURLWithPath: fullPath)) } catch { err = error return } } } } do { let data = try PNG8Image().png8ImageData(atPath: fullPath) try data.write(to: URL(fileURLWithPath: fullPath)) } catch { err = error } } } } }