Files

162 lines
9.9 KiB
Swift

import Foundation
import Combine
private func boundedLibraryData(_ url:URL)throws->Data {
let stream=try FileHandle(forReadingFrom:url)
defer {try? stream.close()}
let data=try stream.read(upToCount:1048577) ?? Data()
guard data.count<=1048576 else {throw CocoaError(.fileReadCorruptFile)}
return data
}
struct OwnedGame:Codable,Identifiable,Equatable {
var id:String
var folder:String
var added:Date
var macRevision:String?
var iosRevision:String?
}
struct OwnedGameOutput:Identifiable {
let item:OwnedGame
var id:String {item.id}
var folder:URL {URL(fileURLWithPath:item.folder)}
var originalBundleID:String?
var titleID:String?
var macApp:URL?
var iosIPA:URL?
var romfs:URL?
var version:String?
var macRevision:String?
var iosRevision:String?
var missing=false
var ready:Bool {macApp != nil || iosIPA != nil}
static func read(_ item:OwnedGame)->OwnedGameOutput {
var output=OwnedGameOutput(item:item)
let fm=FileManager.default
var directory:ObjCBool=false
guard fm.fileExists(atPath:item.folder,isDirectory:&directory),directory.boolValue else {output.missing=true;return output}
func record(_ name:String)->[String:Any] {
guard let data=try? boundedLibraryData(output.folder.appendingPathComponent(name)),data.count<=1048576,
let value=(try? JSONSerialization.jsonObject(with:data)) as? [String:Any],(value["schema"] as? Int ?? 1)==1 else {return [:]}
return value
}
let mac=record("nxnative-mac.json"),ios=record("nxnative-ios.json")
output.macRevision=item.macRevision ?? mac["source_revision"] as? String
output.iosRevision=item.iosRevision ?? ios["source_revision"] as? String
output.originalBundleID=ios["bundle_id"] as? String
if let path=mac["app_path"] as? String {
let bundle=URL(fileURLWithPath:path)
if let data=try? boundedLibraryData(bundle.appendingPathComponent("Contents/Info.plist")),
let info=(try? PropertyListSerialization.propertyList(from:data,format:nil)) as? [String:Any],
let identifier=info["CFBundleIdentifier"] as? String,!identifier.isEmpty,
(mac["bundle_id"] as? String).map({$0==identifier}) ?? true,
fm.fileExists(atPath:bundle.appendingPathComponent("Contents/MacOS/dmhost").path),
(try? bundle.appendingPathComponent("Contents/Resources/romfs").resourceValues(forKeys:[.isDirectoryKey]).isDirectory)==true {
output.macApp=bundle;output.titleID=info["DMTitleID"] as? String ?? mac["title_id"] as? String
output.version=info["CFBundleShortVersionString"] as? String ?? mac["version"] as? String
}
}
if let id=output.titleID,id.range(of:"^[0-9A-Fa-f]{16}$",options:.regularExpression)==nil {output.titleID=nil}
if output.titleID==nil {
let ids=((try? fm.contentsOfDirectory(at:output.folder,includingPropertiesForKeys:nil)) ?? []).compactMap {url->String? in
url.lastPathComponent.range(of:"^[0-9A-Fa-f]{16}_build$",options:.regularExpression) != nil ? String(url.lastPathComponent.prefix(16)).uppercased() : nil
}
if ids.count==1 {output.titleID=ids[0]}
}
output.titleID=output.titleID?.uppercased()
let split=(ios["packaging"] as? String) != "full"
if let identifier=ios["bundle_id"] as? String,!identifier.isEmpty,
let prepared=IOSPackageOutput.load(record:ios,split:split),
prepared.romfs.map({fm.fileExists(atPath:$0.path)}) ?? true {
output.iosIPA=prepared.ipa;output.romfs=prepared.romfs
if output.version==nil {output.version=ios["version"] as? String}
}
return output
}
}
@MainActor final class GameLibrary:ObservableObject {
private struct Document:Codable {let schema:Int;let games:[OwnedGame]}
@Published private(set) var games:[OwnedGameOutput]=[]
@Published var error:String?
private var items:[OwnedGame]=[]
private var writable=true
private let file:URL
init(file:URL?=nil) {
self.file=file ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support/nxnative Easy/library.json")
if FileManager.default.fileExists(atPath:self.file.path) {
do {
let data=try boundedLibraryData(self.file)
guard data.count<=1048576 else {throw CocoaError(.fileReadCorruptFile)}
let document=try JSONDecoder().decode(Document.self,from:data)
guard document.schema==1,Set(document.games.map(\.id)).count==document.games.count,document.games.allSatisfy({!$0.id.isEmpty && $0.folder.hasPrefix("/") && !$0.folder.contains("\0")}) else {throw CocoaError(.fileReadCorruptFile)}
items=document.games
}catch{writable=false;self.error="No pudimos leer tu biblioteca. Conservamos el archivo original sin sobrescribirlo."}
}
refresh()
}
func refresh() {games=items.map(OwnedGameOutput.read)}
@discardableResult func add(_ folder:URL)throws->String {
guard writable else {throw CocoaError(.fileReadCorruptFile)}
let path=folder.resolvingSymlinksInPath().standardizedFileURL.path
if let known=items.first(where:{$0.folder==path}) {refresh();return known.id}
let url=URL(fileURLWithPath:path)
let files=try FileManager.default.contentsOfDirectory(at:url,includingPropertiesForKeys:nil)
guard files.contains(where:{["nsp","xci","nsz","xcz"].contains($0.pathExtension.lowercased()) || ["nxnative-mac.json","nxnative-ios.json"].contains($0.lastPathComponent)}) else {throw CocoaError(.fileReadUnknown)}
let item=OwnedGame(id:UUID().uuidString,folder:path,added:Date())
try persist(items+[item]);return item.id
}
func recordBuild(folder:URL,target:String,revision:String) {
let path=folder.resolvingSymlinksInPath().standardizedFileURL.path
guard let index=items.firstIndex(where:{$0.folder==path}),revision.range(of:"^[0-9a-fA-F]{40}$",options:.regularExpression) != nil else {return}
var changed=items
if target=="mac" {changed[index].macRevision=revision}else if target=="ios" {changed[index].iosRevision=revision}else{return}
do {try persist(changed)}catch{self.error="No pudimos guardar la biblioteca. Tus archivos no se modificaron."}
}
func remove(_ id:String) {do{try persist(items.filter{$0.id != id})}catch{self.error="No pudimos guardar la biblioteca. Tus archivos no se modificaron."}}
private func persist(_ proposed:[OwnedGame])throws {
guard writable else {throw CocoaError(.fileReadCorruptFile)}
let parent=file.deletingLastPathComponent()
try FileManager.default.createDirectory(at:parent,withIntermediateDirectories:true,attributes:[.posixPermissions:0o700])
try FileManager.default.setAttributes([.posixPermissions:0o700],ofItemAtPath:parent.path)
try JSONEncoder().encode(Document(schema:1,games:proposed)).write(to:file,options:.atomic)
try FileManager.default.setAttributes([.posixPermissions:0o600],ofItemAtPath:file.path)
items=proposed;error=nil;refresh()
}
}
// These services read repository metadata, never game payloads or save folders.
enum BuildProvenance {
static func affects(_ path:String,titleID:String)->Bool {
if path.hasSuffix(".md") || path.contains("/tests/") {return false}
return path.hasPrefix("dm/games/"+titleID.uppercased()+"/") || ["dm/host/","dm/gfx/","dm/tools/","dm/ios/","dm/mac/","dm/third_party/","dm/games/game.py","dm/games/profile_guess.py","dm/games/settings.","scripts/build_ipa_from_nsp.py","scripts/build_ios.py","scripts/build_mac.py"].contains(where:path.hasPrefix)
}
static func git(_ repo:URL,_ args:[String])->String? {
let task=Process(),pipe=Pipe();task.executableURL=URL(fileURLWithPath:"/usr/bin/git");task.arguments=["-C",repo.path]+args;task.standardOutput=pipe;task.standardError=FileHandle.nullDevice;task.standardInput=FileHandle.nullDevice
do {try task.run();let data=pipe.fileHandleForReading.readDataToEndOfFile();task.waitUntilExit();return task.terminationStatus==0 ? String(decoding:data,as:UTF8.self).trimmingCharacters(in:.whitespacesAndNewlines) : nil}catch{return nil}
}
static func head(_ repo:URL)->String? {git(repo,["rev-parse","HEAD"])}
static func recommendation(repo:URL,revision:String?,titleID:String)->Bool? {
guard let revision,revision.range(of:"^[0-9a-fA-F]{40}$",options:.regularExpression) != nil,
let paths=git(repo,["diff","--name-only","--no-renames",revision,"--"]) else {return nil}
return paths.split(separator:"\n").contains {affects(String($0),titleID:titleID)}
}
}
enum BuildDiagnostics {
// Deliberately omit Apple account/device identifiers, environment and raw install logs.
static func redact(_ value:String,home:String=NSHomeDirectory())->String {
var out=value.replacingOccurrences(of:home+"/",with:"~/")
let rules:[(String,String)]=[
(#"(?i)([A-Za-z0-9_.-]+\s*=\s*)(?:0x)?[0-9a-f]{16,}"#,"$1[redacted]"),
(#"(?i)(?:[^\s]*)(?:password|passwd|token|authorization|cookie|secret|anisette|apple.?id|udid)[^\n]*"#,"[private authentication/device detail removed]"),
(#"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"#,"[email removed]")]
for (pattern,replacement) in rules {out=out.replacingOccurrences(of:pattern,with:replacement,options:.regularExpression)}
return out
}
static func report(version:String,system:String,revision:String?,target:String,stage:String,log:String)->String {
let tail=log.split(separator:"\n",omittingEmptySubsequences:false).suffix(50).joined(separator:"\n")
return redact("nxnative Easy diagnostics\nApp: \(version)\nSystem: \(system)\nSource: \(revision ?? "unknown")\nTarget: \(target)\nStage: \(stage)\n\nLast build output (up to 50 lines):\n"+tail)
}
}