Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions app/Sources/NotProtonApp/Model/PrefixPrompt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,34 @@ enum PrefixPrompt {
: "Are you sure you want to back up this prefix?"
}

static func backUpGameFilesTitle(_ prefix: WinePrefix?) -> String {
guard let prefix else { return "Back up game files?" }
return "Back up the game files for \(prefix.title)?"
}

static func backUpGameFilesMessage() -> String {
sentences([
"This copies the entire installed game folder to a NotProton snapshot in the same Steam library.",
"It may require free space about as large as the installed game.",
"The Wine prefix and data stored in it are not included. Quit the game before continuing.",
])
}

static func restoreGameFilesTitle(_ backup: GameFilesBackup?) -> String {
guard let backup else { return "Restore game-file snapshot?" }
let when = backup.taken?.formatted(date: .abbreviated, time: .shortened)
?? backup.url.lastPathComponent
return "Restore the game files for \(backup.prefix.title) from \(when)?"
}

static func restoreGameFilesMessage() -> String {
sentences([
"The current game files will first be kept as another snapshot.",
"This restores the game folder only; the Wine prefix and its data are unchanged.",
"Steam may verify or update the game before its next launch.",
])
}

static func rebuildMessage() -> String {
sentences([
"This tool is intended to repair a prefix after switching between Rosetta/FEX CrossOver.",
Expand Down
121 changes: 121 additions & 0 deletions app/Sources/NotProtonApp/Model/PrefixRebuild.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,127 @@ extension PrefixTools {
return slot
}

static func backUpGameFiles(_ prefix: WinePrefix, now: Date = .now) throws -> GameFilesBackup {
guard !PrefixStore.isInUse(prefix) else {
throw StepFailure(
step: "Back up game files",
detail: "\(prefix.title) is running. Quit the game first."
)
}
guard let source = PrefixStore.installDirectory(of: prefix),
let root = PrefixStore.gameBackupRoot(of: prefix)
else {
throw StepFailure(
step: "Back up game files",
detail: "\(prefix.title) has no installed game folder to back up."
)
}

let fm = FileManager.default
let slot = gameBackupSlot(in: root, at: now)
let snapshot = slot.appending(path: source.lastPathComponent)
do {
try WriteRefused.catching(root) {
try fm.createDirectory(at: slot, withIntermediateDirectories: true)
try fm.copyItem(at: source, to: snapshot)
}
} catch let error as WriteRefused {
try? fm.removeItem(at: slot)
throw error
} catch {
try? fm.removeItem(at: slot)
throw StepFailure(
step: "Back up game files",
detail: "Could not copy the full game folder. Check free space and permissions. \(error.localizedDescription)"
)
}

return GameFilesBackup(
prefix: prefix,
url: slot,
gameDirectoryName: source.lastPathComponent,
taken: PrefixStore.gameBackupDate(of: slot)
)
}

@discardableResult
static func restoreGameFiles(_ backup: GameFilesBackup, now: Date = .now) throws -> GameFilesBackup {
let prefix = backup.prefix
guard !PrefixStore.isInUse(prefix) else {
throw StepFailure(
step: "Restore game files",
detail: "\(prefix.title) is running. Quit the game first."
)
}
guard let current = PrefixStore.installDirectory(of: prefix),
let root = PrefixStore.gameBackupRoot(of: prefix),
backup.url.deletingLastPathComponent().standardizedFileURL == root.standardizedFileURL,
backup.url.lastPathComponent.hasPrefix("\(PrefixStore.gameBackupPrefix)-"),
backup.gameDirectoryName == current.lastPathComponent
else {
throw StepFailure(
step: "Restore game files",
detail: "The selected backup does not match this game's current install folder."
)
}

let source = backup.gameFiles
let fm = FileManager.default
let sourceValues = try? source.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
guard sourceValues?.isDirectory == true, sourceValues?.isSymbolicLink != true else {
throw StepFailure(
step: "Restore game files",
detail: "The selected game backup is missing or invalid."
)
}

let rollbackSlot = gameBackupSlot(in: root, at: now)
let rollbackGame = rollbackSlot.appending(path: current.lastPathComponent)
let staging = current.deletingLastPathComponent()
.appending(path: ".notproton-restore-\(UUID().uuidString)")
do {
try WriteRefused.catching(current) {
try fm.createDirectory(at: rollbackSlot, withIntermediateDirectories: true)
try fm.moveItem(at: current, to: rollbackGame)
do {
try fm.copyItem(at: source, to: staging)
try fm.moveItem(at: staging, to: current)
} catch {
try? fm.removeItem(at: staging)
try fm.moveItem(at: rollbackGame, to: current)
try? fm.removeItem(at: rollbackSlot)
throw error
}
}
} catch let error as WriteRefused {
throw error
} catch {
throw StepFailure(
step: "Restore game files",
detail: "Could not restore the selected snapshot. The previous game files were preserved when possible. \(error.localizedDescription)"
)
}

return GameFilesBackup(
prefix: prefix,
url: rollbackSlot,
gameDirectoryName: current.lastPathComponent,
taken: PrefixStore.gameBackupDate(of: rollbackSlot)
)
}

private static func gameBackupSlot(in root: URL, at now: Date) -> URL {
let stamp = PrefixStore.backupClock().string(from: now)
let fm = FileManager.default
var slot = root.appending(path: "\(PrefixStore.gameBackupPrefix)-\(stamp)")
var next = 2
while fm.fileExists(atPath: slot.path(percentEncoded: false)) {
slot = root.appending(path: "\(PrefixStore.gameBackupPrefix)-\(stamp)-\(next)")
next += 1
}
return slot
}

private static func backupSlot(in root: URL, at now: Date) -> URL {
let stamp = PrefixStore.backupClock().string(from: now)

Expand Down
94 changes: 93 additions & 1 deletion app/Sources/NotProtonApp/Model/PrefixStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,21 @@ struct PrefixBackup: Sendable, Hashable, Identifiable {
var title: String { prefix.title }
}

struct GameFilesBackup: Sendable, Hashable, Identifiable {
let prefix: WinePrefix
let url: URL
let gameDirectoryName: String
let taken: Date?

var id: String { url.path(percentEncoded: false) }
var gameFiles: URL { url.appending(path: gameDirectoryName) }
}

enum PrefixStore {

static let gameBackupFolder = ".notproton-game-backups"
static let gameBackupPrefix = "game.previous"

static func libraries(vdf: URL = SupportPaths.Steam.libraryFoldersVDF) -> [SteamLibrary] {
guard let text = try? String(contentsOf: vdf, encoding: .utf8) else {
return [library(at: SupportPaths.Steam.userData)]
Expand Down Expand Up @@ -139,8 +152,87 @@ enum PrefixStore {
static func installDirectory(of prefix: WinePrefix) -> URL? {
guard let folder = manifestValue("installdir", appID: prefix.appID, in: prefix.library)
else { return nil }
guard !folder.isEmpty, folder != ".", folder != "..",
!folder.contains("/"), !folder.contains("\\")
else { return nil }
let url = prefix.library.steamapps.appending(path: "common").appending(path: folder)
return FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) ? url : nil
guard
let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]),
values.isDirectory == true, values.isSymbolicLink != true
else { return nil }
return url
}

static func gameBackupRoot(of prefix: WinePrefix) -> URL? {
guard prefix.appID.allSatisfy(\.isNumber) else { return nil }
let common = prefix.library.steamapps.appending(path: "common")
guard
let values = try? common.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]),
values.isDirectory == true, values.isSymbolicLink != true
else { return nil }
let base = common.appending(path: gameBackupFolder)
let root = base.appending(path: prefix.appID)

for directory in [base, root] {
let values = try? directory.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
if values?.isSymbolicLink == true { return nil }
if FileManager.default.fileExists(atPath: directory.path(percentEncoded: false)),
values?.isDirectory != true { return nil }
}
return root
}

static func gameBackups(of prefix: WinePrefix) -> [GameFilesBackup] {
guard let root = gameBackupRoot(of: prefix) else { return [] }
let fm = FileManager.default
let entries = (try? fm.contentsOfDirectory(
at: root, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
)) ?? []

return entries.compactMap { entry in
let name = entry.lastPathComponent
guard name.hasPrefix("\(gameBackupPrefix)-") else { return nil }
guard let backupValues = try? entry.resourceValues(
forKeys: [.isDirectoryKey, .isSymbolicLinkKey]
), backupValues.isDirectory == true, backupValues.isSymbolicLink != true else { return nil }
let children = (try? fm.contentsOfDirectory(
at: entry, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
)) ?? []
guard children.count == 1,
let game = children.first,
let values = try? game.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]),
values.isDirectory == true, values.isSymbolicLink != true
else { return nil }

let stamp = name.dropFirst(gameBackupPrefix.count + 1).prefix(15)
return GameFilesBackup(
prefix: prefix,
url: entry,
gameDirectoryName: game.lastPathComponent,
taken: backupClock().date(from: String(stamp))
)
}
.sorted { ($0.taken ?? .distantPast) > ($1.taken ?? .distantPast) }
}

static func gameBackupDate(of backup: URL) -> Date? {
let name = backup.lastPathComponent
guard name.hasPrefix("\(gameBackupPrefix)-") else { return nil }
let stamp = name.dropFirst(gameBackupPrefix.count + 1).prefix(15)
return backupClock().date(from: String(stamp))
}

static func workshopDirectory(of prefix: WinePrefix) -> URL? {
let url = prefix.library.steamapps
.appending(path: "workshop/content")
.appending(path: prefix.appID)
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(
atPath: url.path(percentEncoded: false), isDirectory: &isDirectory
), isDirectory.boolValue else { return nil }
return url
}

private static func manifestValue(_ key: String, appID: String, in library: SteamLibrary) -> String? {
Expand Down
49 changes: 49 additions & 0 deletions app/Sources/NotProtonApp/PrefixesModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ final class PrefixesModel {
case rebuild([WinePrefix], InstalledTool)
case backUp([WinePrefix])
case deleteBackups([PrefixBackup])
case backUpGameFiles(WinePrefix)
case restoreGameFiles(GameFilesBackup)
}

var selectedPrefix: WinePrefix? {
Expand Down Expand Up @@ -145,6 +147,53 @@ final class PrefixesModel {
PrefixTools.reveal(prefix)
}

func gameDirectory(for prefix: WinePrefix) -> URL? {
PrefixStore.installDirectory(of: prefix)
}

func revealGameDirectory(_ prefix: WinePrefix) {
guard let directory = gameDirectory(for: prefix) else { return }
PrefixTools.reveal(at: directory)
}

func workshopDirectory(for prefix: WinePrefix) -> URL? {
PrefixStore.workshopDirectory(of: prefix)
}

func revealWorkshopDirectory(_ prefix: WinePrefix) {
guard let directory = workshopDirectory(for: prefix) else { return }
PrefixTools.reveal(at: directory)
}

func gameBackups(for prefix: WinePrefix) -> [GameFilesBackup] {
PrefixStore.gameBackups(of: prefix)
}

func gameBackupDirectory(for prefix: WinePrefix) -> URL? {
PrefixStore.gameBackupRoot(of: prefix)
}

func revealGameBackupDirectory(_ prefix: WinePrefix) {
guard let directory = gameBackupDirectory(for: prefix),
FileManager.default.fileExists(atPath: directory.path(percentEncoded: false))
else { return }
PrefixTools.reveal(at: directory)
}

func backUpGameFiles(_ prefix: WinePrefix) async {
await eachInTurn([prefix], { try PrefixTools.backUpGameFiles($0) }) { saved in
guard let first = saved.first else { return nil }
return "Backed up the game files for \(first.prefix.title)."
}
}

func restoreGameFiles(_ backup: GameFilesBackup) async {
await eachInTurn([backup.prefix], { _ in try PrefixTools.restoreGameFiles(backup) }) { restored in
guard let first = restored.first else { return nil }
return "Restored the game files for \(first.prefix.title). The previous files were kept as a snapshot."
}
}

func delete(_ targets: [WinePrefix]) async {
await eachInTurn(targets, { try PrefixTools.delete($0) }) { deleted in
if deleted.count == 1 {
Expand Down
Loading