steamsync: Add support for Non-Steam apps

master
Taeyeon Mori 4 years ago
parent 8edb723f09
commit 908fe47ff5
  1. 33
      bin/sync_savegames
  2. 205
      lib/python/steamsync.py
  3. 4
      lib/python/steamutil.py

@ -1,21 +1,22 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
#pylint:disable=no-member #pylint:disable=no-member
from getpass import getuser
from pathlib import Path from pathlib import Path
from steamsync import SteamSync, SyncOp from steamsync import SteamSync
# Find home sync = SteamSync(Path("~/Nextcloud/Misc/Savegames").expanduser())
steamns_home = Path("~/.local/steam/home/taeyeon/").expanduser()
# Find home
steamns_home = Path("~/.local/steam/home/%s/" % getuser()).expanduser()
if steamns_home.exists(): if steamns_home.exists():
home = steamns_home sync.home_path = steamns_home
else:
home = Path("~").expanduser()
sync = SteamSync(Path("~/Nextcloud/Misc/Savegames").expanduser())
### -----------------------------------------------------------------
# Steam Games
### -----------------------------------------------------------------
@sync.by_name("zanzarah") @sync.by_name("zanzarah")
def zanzarah(op): def zanzarah(op):
with op.game_directory.prefix("Save") as set: with op.game_directory.prefix("Save") as set:
@ -36,8 +37,22 @@ def fs19(op):
#@sync.by_name("Fell Seal") #@sync.by_name("Fell Seal")
def fell_seal(op): def fell_seal(op):
with op.from_(home).prefix("Fell Seal") as set: with op.home.prefix("Fell Seal") as set:
set += "saves" set += "saves"
set += "customdata" set += "customdata"
set.execute() set.execute()
### -----------------------------------------------------------------
# Other Games
### -----------------------------------------------------------------
@sync.generic("Final Fantasy XIV", platform="win")
def ffxiv(op):
path = op.my_documents.prefix("My Games/FINAL FANTASY XIV - A Realm Reborn")
if not path.exists():
return
with path as set:
set += "FFXIV_CHR*/*.dat"
if set.show_confirm():
set.execute()

@ -13,20 +13,28 @@ from typing import Tuple, Dict, List, Union, Set, Callable, Any
from steamutil import Steam, App, CachedProperty, MalformedManifestError from steamutil import Steam, App, CachedProperty, MalformedManifestError
class AppNotFoundType: ### -----------------------------------------------------------------
def __call__(self, func) -> None: # Sync Abstractions
pass ### -----------------------------------------------------------------
def __bool__(self) -> bool:
return False
AppNotFound = AppNotFoundType()
class SyncPath: class SyncPath:
"""
A SyncPath represents a pair of paths:
local/common ; target/common
Whereby target is the location being synched to and local is
the prefix the data is synched from on the local machine.
Common has components common to both paths.
Usually, you'd set the local prefix and then the common part.
e.g.: op.home.prefix(".my_game") / "Savegames"
whereby "Savegames" is included in the resulting target
path, but ".my_game" is not.
Note that SyncPath should be considered immutable. Relevant
methods return a new instance.
"""
__slots__ = "op", "local", "common" __slots__ = "op", "local", "common"
op: 'SyncOp' op: 'AbstractSyncOp'
local: Path local: Path
common: Path common: Path
@ -34,21 +42,32 @@ class SyncPath:
self.op = op self.op = op
self.local = Path(local) self.local = Path(local)
self.common = Path(common) self.common = Path(common)
## Change paths
def prefix(self, component: Union[str, Path]) -> 'SyncPath': def prefix(self, component: Union[str, Path]) -> 'SyncPath':
""" Return a new SyncPath that has a component prefixed to the local path """
return SyncPath(self.op, self.local / component, self.common) return SyncPath(self.op, self.local / component, self.common)
def __div__(self, component: Union[str, Path]) -> 'SyncPath': def __div__(self, component: Union[str, Path]) -> 'SyncPath':
""" Return a new SyncPath that nas a component added """
return SyncPath(self.op, self.local, self.common / component) return SyncPath(self.op, self.local, self.common / component)
## Retrieve paths
@property @property
def path(self) -> Path: def path(self) -> Path:
""" Get the local path """
return self.local / self.common return self.local / self.common
def exists(self) -> bool:
""" Chech whether local path exists """
return self.path.exists()
@property @property
def target_path(self) -> Path: def target_path(self) -> Path:
""" Get the sync target path """
return self.op.target_path / self.common return self.op.target_path / self.common
## Begin a SyncSet
def __enter__(self) -> 'SyncSet': def __enter__(self) -> 'SyncSet':
return SyncSet(self) return SyncSet(self)
@ -58,9 +77,13 @@ class SyncPath:
class SyncSet: class SyncSet:
"""
A SyncSet represents a set of files to be synchronized
from a local to a target location represented by a SyncPath
"""
FileStatSet = Dict[Path, Tuple[Path, os.stat_result]] FileStatSet = Dict[Path, Tuple[Path, os.stat_result]]
op: 'SyncOp' op: 'AbstractSyncOp'
spath: SyncPath spath: SyncPath
local: FileStatSet local: FileStatSet
target: FileStatSet target: FileStatSet
@ -74,10 +97,12 @@ class SyncSet:
@property @property
def path(self) -> Path: def path(self) -> Path:
""" The local path """
return self.spath.path return self.spath.path
@property @property
def target_path(self) -> Path: def target_path(self) -> Path:
""" The target path """
return self.spath.target_path return self.spath.target_path
# Modify inclusion # Modify inclusion
@ -131,12 +156,16 @@ class SyncSet:
def files_unmodified(self) -> Set[Path]: def files_unmodified(self) -> Set[Path]:
return (self.local.keys() | self.target.keys()) - (self.files_from_local | self.files_from_target) return (self.local.keys() | self.target.keys()) - (self.files_from_local | self.files_from_target)
def show_confirm(self) -> bool: def show_confirm(self, skip=True) -> bool:
# XXX: move to SyncOp? # XXX: move to SyncOp?
print(" Local is newer: ", ", ".join(map(str, self.files_from_local))) print(" Local is newer: ", ", ".join(map(str, self.files_from_local)))
print(" Target is newer: ", ", ".join(map(str, self.files_from_target))) print(" Target is newer: ", ", ".join(map(str, self.files_from_target)))
print(" Unmodified: ", ", ".join(map(str, self.files_unmodified))) print(" Unmodified: ", ", ".join(map(str, self.files_unmodified)))
if skip and not self.files_from_local and not self.files_from_target:
print("Noting to do!")
return False
print("Continue? <Y/n> ", end="") print("Continue? <Y/n> ", end="")
resp = input().strip() resp = input().strip()
if resp.lower() in ("y", "yes", ""): if resp.lower() in ("y", "yes", ""):
@ -157,20 +186,43 @@ class SyncSet:
return self.op._do_copy(operations) return self.op._do_copy(operations)
class SyncOp: ### -----------------------------------------------------------------
# Sync Operation
### -----------------------------------------------------------------
class AbstractSyncOp:
parent: 'SteamSync' parent: 'SteamSync'
app: App
def __init__(self, ssync, app): def __init__(self, parent: 'SteamSync'):
self.parent = ssync self.parent = parent
self.app = app
# Properties
def __call__(self, func: Callable[['SyncOp'],Any]): @property
def name(self):
""" Name of the app """
raise NotImplementedError()
@property
def slug(self):
""" Name of the destination folder """
return self.name
@slug.setter
def slug(self, value: str):
dict(self)["slug"] = value
@property
def target_path(self) -> Path:
""" Full path to copy saves to """
return self.parent.target_path / self.slug
def __call__(self, func: Callable[['AbstractSyncOp'], Any]):
# For decorator use # For decorator use
self._report_begin() self._report_begin()
return func(self) return func(self)
def _do_copy(self, ops: List[Tuple[Path, Path]]) -> bool: # Actual Copy Logic
@staticmethod
def _do_copy(ops: List[Tuple[Path, Path]]) -> bool:
for src, dst in ops: for src, dst in ops:
if not dst.parent.exists(): if not dst.parent.exists():
dst.parent.mkdir(parents=True) dst.parent.mkdir(parents=True)
@ -178,25 +230,24 @@ class SyncOp:
print(" \033[36m%s -> %s\033[0m" % (src, dst)) print(" \033[36m%s -> %s\033[0m" % (src, dst))
shutil.copy2(src, dst) shutil.copy2(src, dst)
return True return True
# UI # UI
def _report_begin(self): def _report_begin(self):
print("\033[34mNow Synchronizing App %s\033[0m" % self.app.name) print("\033[34mNow Synchronizing App \033[36m%s\033[34m (%s)\033[0m"
% (self.name, self.__class__.__name__.replace("SyncOp", "")))
def report_error(self, msg: List[str]): def report_error(self, msg: List[str]):
print("\033[31m"+"\n".join(" " + l for l in msg)+"\033[0m") print("\033[31m"+"\n".join(" " + l for l in msg)+"\033[0m")
@CachedProperty # Start from here
def target_path(self) -> Path: @property
return self.parent.target_path / self.app.install_dir def home(self):
return SyncPath(self, self.parent.home_path)
@CachedProperty @CachedProperty
def my_documents(self) -> SyncPath: def my_documents(self) -> SyncPath:
""" Get the Windows "My Documents" folder """ """ Get the Windows "My Documents" folder """
if sys.platform.startswith("linux"): if sys.platform == "win32":
# TODO: what about native games?
return SyncPath(self, self.app.compat_drive / "users/steamuser/My Documents")
elif sys.platform == "win32":
def get_my_documents(): def get_my_documents():
import ctypes.wintypes import ctypes.wintypes
CSIDL_PERSONAL = 5 # My Documents CSIDL_PERSONAL = 5 # My Documents
@ -208,36 +259,92 @@ class SyncOp:
return buf.value return buf.value
return SyncPath(self, get_my_documents()) return SyncPath(self, get_my_documents())
else: else:
raise Exception("Platform not supported") raise RuntimeError("Platform has unknown My Documents location")
def from_(self, path: Path) -> SyncPath:
return SyncPath(self, path)
class GenericSyncOp(AbstractSyncOp):
""" Generic Sync Operation for Non-Steam Apps """
name: str = None
def __init__(self, parent, name):
super().__init__(parent)
self.name = name
class SteamSyncOp(AbstractSyncOp):
""" Sync Operation for Steam Apps """
app: App
def __init__(self, ssync, app):
super().__init__(ssync)
self.app = app
## Implement AbstractSyncOp
@property
def name(self):
return self.app.name
@property
def slug(self):
return self.app.install_dir
## Addidtional information available through Steam
@property @property
def game_directory(self) -> SyncPath: def game_directory(self) -> SyncPath:
return SyncPath(self, self.app.install_path) return SyncPath(self, self.app.install_path)
def from_(self, path: Path) -> SyncPath:
return SyncPath(self, path)
@CachedProperty
def my_documents(self) -> SyncPath:
if sys.platform.startswith("linux"):
# TODO: what about native games?
return SyncPath(self, self.app.compat_drive / "users/steamuser/My Documents")
else:
return super().my_documents
class SyncNoOp:
""" No-Op Sync Operation """
def __call__(self, func) -> None:
pass
def __bool__(self) -> bool:
return False
AppNotFound = SyncNoOp()
### -----------------------------------------------------------------
# Main Sync manager class
### -----------------------------------------------------------------
class SteamSync: class SteamSync:
target_path: Path target_path: Path
steam: Steam steam: Steam
home_path: Path
def __init__(self, target_path: Path, *, steam_path: Path = None): def __init__(self, target_path: Path, *, steam_path: Path = None):
self.target_path = Path(target_path) self.target_path = Path(target_path)
self.steam = Steam(steam_path) self.steam = Steam(steam_path)
self.home_path = Path("~").expanduser()
# Get Information
@CachedProperty @CachedProperty
def apps(self) -> List[App]: def apps(self) -> List[App]:
return list(self.steam.apps) return list(self.steam.apps)
# Get Sync Operation for a specific App
def by_id(self, appid): def by_id(self, appid):
""" Steam App by AppID """
app = self.steam.get_app(appid) app = self.steam.get_app(appid)
if app is not None: if app is not None:
return SyncOp(self, app) return SteamSyncOp(self, app)
else: else:
return AppNotFound return AppNotFound
def by_name(self, pattern): def by_name(self, pattern):
""" Steam App by Name """
pt = re.compile(fnmatch.translate(pattern).rstrip("\\Z"), re.IGNORECASE) pt = re.compile(fnmatch.translate(pattern).rstrip("\\Z"), re.IGNORECASE)
app = None app = None
for candidate in self.apps: #pylint:disable=not-an-iterable for candidate in self.apps: #pylint:disable=not-an-iterable
@ -245,4 +352,12 @@ class SteamSync:
if app is not None: if app is not None:
raise Exception("Encountered more than one possible App matching '%s'" % pattern) raise Exception("Encountered more than one possible App matching '%s'" % pattern)
app = candidate app = candidate
return SyncOp(self, app) if app is None:
return AppNotFound
return SteamSyncOp(self, app)
def generic(self, name, *, platform=None):
""" Non-Steam App """
if platform is not None and platform not in sys.platform:
return AppNotFound
return GenericSyncOp(self, name)

@ -332,8 +332,8 @@ class Steam:
except WindowsError: except WindowsError:
pass pass
# try PROGRAMFILES # try PROGRAMFILES
pfiles = (os.environ.get("ProgramFiles(x86)", "C:\Program Files (x86)"), pfiles = (os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"),
os.environ.get("ProgramFiles", "C:\Program Files")) os.environ.get("ProgramFiles", "C:\\Program Files"))
for path in pfiles: for path in pfiles:
if path.exists(): if path.exists():
path /= "Steam" path /= "Steam"

Loading…
Cancel
Save