about summary refs log tree commit diff
path: root/pkgs/games
diff options
context:
space:
mode:
authoraszlig <aszlig@nix.build>2018-02-18 10:53:38 +0100
committeraszlig <aszlig@nix.build>2018-02-21 20:24:51 +0100
commit73b4b0bfb78998a53b73ce2501f4d2b8bc73198d (patch)
treed0f7a985542c8a7562aab18fa9fb9808c49f3c6c /pkgs/games
parente8eb9e5db9492972ac932c8bc5a94009bc6aa236 (diff)
pkgs/games: Add a basic fetcher for GOG
We're in almost the same misery here as with HumbleBundle, because we
can't get stable download URLs with manifests for games that have an
"os" field of either "windows" or "osx".

Unfortunately, this isn't the only hoop we're going to, because similar
to the HumbleBundle fetcher, we need to solve a (re)captcha in order to
login (and I've been annoyed by countless cars, street signs, roads,
busses, store fronts, hills and mountains during development).

I tried looking for a way to get an API key similar to what itch.io is
doing, but the only mechanism in place is a temporary key which needs to
be refreshed using the old key after a while, so it's unsuitable for our
case.

The code currently is very much _not_ DRY, because a lot of it is copied
over and modified from fetchHumbleBundle, especially the captcha solving
application.

It's really a shame to see the same situation that we have for
HumbleBundle in GOG, given that HB is getting less DRM-free games these
days and GOG is DRM-free *only* (which is a very good thing).

Signed-off-by: aszlig <aszlig@nix.build>
Diffstat (limited to 'pkgs/games')
-rw-r--r--pkgs/games/default.nix2
-rw-r--r--pkgs/games/gog/default.nix39
-rw-r--r--pkgs/games/gog/fetch-gog/default.nix243
3 files changed, 283 insertions, 1 deletions
diff --git a/pkgs/games/default.nix b/pkgs/games/default.nix
index dd97148d..ae86d30e 100644
--- a/pkgs/games/default.nix
+++ b/pkgs/games/default.nix
@@ -42,7 +42,7 @@ let
   packages = (pkgs.lib.evalModules {
     modules = [
       (if config == null then configFile else config)
-      baseModule ./humblebundle ./steam ./itch
+      baseModule ./humblebundle ./steam ./itch ./gog
     ];
   }).config.packages;
 
diff --git a/pkgs/games/gog/default.nix b/pkgs/games/gog/default.nix
new file mode 100644
index 00000000..111091c3
--- /dev/null
+++ b/pkgs/games/gog/default.nix
@@ -0,0 +1,39 @@
+{ config, lib, pkgs, ... }:
+
+let
+  cfg = config.gog;
+
+  self = rec {
+    callPackage = pkgs.lib.callPackageWith (pkgs // self);
+    callPackage_i686 = pkgs.lib.callPackageWith (pkgs.pkgsi686Linux // self);
+
+    fetchGog = callPackage ./fetch-gog {
+      inherit (config.gog) email password;
+    };
+  };
+in {
+  options.gog = {
+    email = lib.mkOption {
+      type = lib.types.nullOr lib.types.str;
+      default = null;
+      description = ''
+        Email address for your GOG account.
+      '';
+    };
+
+    password = lib.mkOption {
+      type = lib.types.nullOr lib.types.str;
+      default = null;
+      description = ''
+        Password for your GOG account.
+
+        <note><para>This will end up in the Nix store and other users on the
+        same machine can read it!</para></note>
+      '';
+    };
+  };
+
+  config.packages = {
+    gog = lib.mkIf (cfg.email != null && cfg.password != null) self;
+  };
+}
diff --git a/pkgs/games/gog/fetch-gog/default.nix b/pkgs/games/gog/fetch-gog/default.nix
new file mode 100644
index 00000000..3389eb60
--- /dev/null
+++ b/pkgs/games/gog/fetch-gog/default.nix
@@ -0,0 +1,243 @@
+{ stdenv, lib, curl, writeText, runCommandCC, python3Packages, cacert
+, pkgconfig, qt5
+
+, email, password
+}:
+
+{ productId, downloadName, sha256, downloadType ? "installer", suffix ? "sh"
+, name ? "${toString productId}-${downloadName}-${downloadType}.${suffix}"
+}:
+
+let
+  getCaptcha = let
+    mkCString = val: let
+      escaped = lib.replaceStrings ["\"" "\\" "\n"] ["\\\"" "\\\\" "\\n"] val;
+    in "\"${escaped}\"";
+
+    injectedJS = ''
+      var user_input = document.getElementById('login_username');
+      var pass_input = document.getElementById('login_password');
+      var submit_button = document.getElementById('login_login');
+
+      user_input.value = ${builtins.toJSON email};
+      user_input.style.display = 'none';
+      pass_input.value = ${builtins.toJSON password};
+      pass_input.style.display = 'none';
+
+      submit_button.style.display = 'none';
+
+      function waitForResponse() {
+        var response = grecaptcha.getResponse();
+        if (response != "")
+          submit_button.click();
+        else
+          setTimeout(waitForResponse, 50);
+      }
+
+      waitForResponse();
+    '';
+
+    application = writeText "captcha.cc" ''
+      #include <QApplication>
+      #include <QWebEngineView>
+      #include <QTcpServer>
+      #include <QQuickWebEngineProfile>
+      #include <QUrlQuery>
+
+      // Taken from lgogdownloader (https://github.com/Sude-/lgogdownloader):
+      static QString clientId = "46899977096215655";
+      static QString clientSecret =
+        "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9";
+      static QString redirectUri =
+        "https://embed.gog.com/on_login_success?origin=client";
+
+      static QUrl getAuthUrl() {
+        QUrl url("https://auth.gog.com/auth");
+
+        QUrlQuery query;
+        query.addQueryItem("client_id", clientId);
+        query.addQueryItem("redirect_uri", redirectUri);
+        query.addQueryItem("response_type", "code");
+        query.addQueryItem("layout", "default");
+        query.addQueryItem("brand", "gog");
+
+        url.setQuery(query);
+        return url;
+      }
+
+      static QUrl getTokenUrl(const QString &code) {
+        QUrl url("https://auth.gog.com/token");
+
+        QUrlQuery query;
+        query.addQueryItem("client_id", clientId);
+        query.addQueryItem("client_secret", clientSecret);
+        query.addQueryItem("grant_type", "authorization_code");
+        query.addQueryItem("code", code);
+        query.addQueryItem("redirect_uri", redirectUri);
+
+        url.setQuery(query);
+        return url;
+      }
+
+      int main(int argc, char **argv) {
+        QApplication *app = new QApplication(argc, argv);
+        QTcpServer *server = new QTcpServer();
+        QWebEngineView *browser = new QWebEngineView();
+
+        QQuickWebEngineProfile::defaultProfile()->setOffTheRecord(true);
+
+        if (!server->listen(QHostAddress::LocalHost, 18321)) {
+          qCritical() << "Unable to listen on port 18321!";
+          return 1;
+        }
+
+        qInfo() << "Waiting for connection from the GOG login helper...";
+        if (!server->waitForNewConnection(-1)) {
+          qCritical() << "Unable to accept the connection!";
+          return 1;
+        }
+        qInfo() << "Connection established, spawning window to login.";
+
+        QTcpSocket *sock = server->nextPendingConnection();
+
+        browser->load(getAuthUrl());
+        browser->show();
+
+        browser->connect(browser, &QWebEngineView::loadFinished, [=]() {
+          browser->page()->runJavaScript(${mkCString injectedJS});
+          browser->connect(
+            browser, &QWebEngineView::urlChanged, [=](const QUrl &newurl
+          ) {
+            QUrlQuery newquery(newurl.query());
+            QString code = newquery.queryItemValue("code");
+            sock->write(getTokenUrl(code).toEncoded());
+            sock->flush();
+            sock->waitForBytesWritten();
+            sock->close();
+            server->close();
+            app->quit();
+          });
+        });
+
+        return app->exec();
+      }
+    '';
+
+  in runCommandCC "get-captcha" {
+    nativeBuildInputs = [ pkgconfig ];
+    buildInputs = [ qt5.qtbase qt5.qtwebengine ];
+  } ''
+    g++ $(pkg-config --libs --cflags Qt5WebEngineWidgets Qt5WebEngine) \
+      -Wall -std=c++11 -o "$out" ${application}
+  '';
+
+  mkPyStr = str: "'${stdenv.lib.escape ["'" "\\"] (toString str)}'";
+
+  fetcher = writeText "fetch-gog.py" ''
+    import sys, socket, time
+    from urllib.request import urlopen, Request
+    from urllib.error import HTTPError
+    from json import loads
+
+    from tabulate import tabulate
+
+    class GogFetcher:
+      def __init__(self, product_id, download_type, download_name):
+        sys.stderr.write("Solving a captcha is required to log in.\n")
+        sys.stderr.write("Please run " ${mkPyStr getCaptcha} " now.\n")
+        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        sys.stderr.write("Waiting for connection")
+        i = 0
+        while sock.connect_ex(("127.0.0.1", 18321)) != 0:
+          time.sleep(0.1)
+          if i % 10 == 0:
+            sys.stderr.write('.')
+            sys.stderr.flush()
+          i += 1
+        sys.stderr.write(" connected.\n")
+        sys.stderr.write("Waiting for captcha to be solved...\n")
+        token_url = sock.recv(4096)
+        sock.close()
+        sys.stderr.write("Captcha solved correctly, logging in.\n")
+        response = urlopen(
+          token_url.decode(),
+          cafile=${mkPyStr "${cacert}/etc/ssl/certs/ca-bundle.crt"}
+        )
+
+        self.product_id = product_id
+        self.download_type = download_type
+        self.download_name = download_name
+        self.access_token = loads(response.read())['access_token']
+
+      def request(self, url):
+        headers = {"Authorization": "Bearer " + self.access_token}
+        return loads(urlopen(
+          Request(url, headers=headers),
+          cafile=${mkPyStr "${cacert}/etc/ssl/certs/ca-bundle.crt"}
+        ).read())
+
+      def strip_dtype(self, dtype):
+        if dtype.endswith('es'):
+          return dtype[:-2]
+        elif dtype.endswith('s'):
+          return dtype[:-1]
+        else:
+          return dtype
+
+      def list_downloads(self):
+        url = "https://api.gog.com/products/" + self.product_id \
+            + "?expand=downloads"
+        downloads = self.request(url)['downloads']
+        table = []
+        for dtype, dloads in downloads.items():
+          for dload in dloads:
+            for dlfile in dload['files']:
+              table.append([
+                self.strip_dtype(dtype), dload.get('os', ""),
+                dload.get('language_full', ""),
+                dlfile['id'], dlfile['size'], dlfile['downlink']
+              ])
+        sys.stderr.write(tabulate(table, headers=[
+          'Download type',
+          'Operating system',
+          'Language',
+          'Identifier',
+          'Size',
+          'URL'
+        ]) + "\n")
+
+      def fetch(self):
+        url = "http://api.gog.com/products/" + self.product_id \
+            + "/downlink/" + self.download_type + "/" + self.download_name
+        try:
+          download = self.request(url)
+        except HTTPError:
+          m = "Download {!r} with type {!r} not found.\nValid downloads are:\n"
+          sys.stderr.write(m.format(self.download_name, self.download_type))
+          self.list_downloads()
+          raise SystemExit(1)
+        else:
+          print(download['downlink'])
+
+    GogFetcher(sys.argv[1], sys.argv[2], sys.argv[3]).fetch()
+  '';
+
+in stdenv.mkDerivation {
+  inherit name;
+  outputHashAlgo = "sha256";
+  outputHash = sha256;
+
+  nativeBuildInputs = [ curl python3Packages.tabulate ];
+
+  buildCommand = ''
+    url="$(${python3Packages.python.interpreter} ${fetcher} \
+      ${toString productId} \
+      ${lib.escapeShellArg downloadType} \
+      ${lib.escapeShellArg downloadName})"
+    header "downloading $name from $url"
+    curl \
+      --cacert ${lib.escapeShellArg "${cacert}/etc/ssl/certs/ca-bundle.crt"} \
+      --fail --output "$out" "$url"
+    stopNest
+  '';
+}