trying to make standalone game w/ Lectrote

It’s trying to run the NPM electron-packager tool, which should have been installed by “npm install” (in the node_modules subdirectory).

But Windows just has some different layout that I don’t get. Or maybe it’s a forward-slash / backward-slash compatibility problem.

Sorry this is such a drag.

Hmm. Is the node_modules subdirectory supposed to be in the lectrote directory, or in the node.js directory, or in the python/lib directory?

I think I assumed the lectrote directory, but I may have misinterpreted.

In the lectrote directory.

Mike - if you want to do a skype or similar web screen sharing, I can probably get you through this…

I’ve been working through this…my notes:

The packages.json preinstall script is for BASH. There’s an npm library called run-script-os that allows you to define platform specific scripts (preinstall:win32). This statement has to be written for CMD on Windows.

"scripts": { "start": "electron .", "preinstall": "run-script-os", "preinstall:macos": "if [ -f quixe/LICENSE ]; then echo Quixe already installed; elif [ -d .git ]; then git submodule init; git submodule update; else git clone https://github.com/erkyrath/quixe.git; fi", "preinstall:darwin": "if [ -f quixe/LICENSE ]; then echo Quixe already installed; elif [ -d .git ]; then git submodule init; git submodule update; else git clone https://github.com/erkyrath/quixe.git; fi", "preinstall:win32": "if EXISTS quixe/LICENSE ( echo Quixe already installed) else (if EXISTS .git ( git submodule init; git submodule update; ) else (git clone https://github.com/erkyrath/quixe.git; fi)" },

This allows npm install to run correctly on windows.

Even so, the makedist.py is clearly meant for a BASH/mac environment as well and so far I can’t get it to run on Windows. I’ve inched closed, but this seems sort of counter-productive since electron has excellent multi-platform build capabilities. (zarf has mentioned he “should” port this to JS, but I assume this is not a priority)

I’m going to dig into this some more and try to get the build working without python.

I assume this works on linux and macos and won’t look at those targets.

Yeah, I was able to get npm install to work, eventually, by dropping in the quixe files manually and removing the “if [ -f quixe/LICENSE ];” from the preinstall script (this was craiglocke’s solution in this thread.

It’s makedist.py that’s giving me the problems now.

Closer. I have dist\win32-* folders created with results. Zip process is last step.

I added bestzip and updated win32 rmdir instead of rm.

Now using “makedist.py --game shadow” and realizing --game is never even checked in the program. Adding more platformy things…

More work on --game gamedir completed. I have it building into gamedir and copying to dist\gamedir-win32-x64, but the extra files aren’t getting copied and not sure yet how to make it default to built-in game.

pycharm is the bomb (JetBrains python IDE with debugger)

I’m pretty close to getting makedist.py work for win32. Of course once I do this, I’ll probably just turn this into a gulp task.

But I need a break. Stupid Brewers blew second game in a row…sheesh

Hey Zarf,

Maybe you can clarify the folder structure for me…

Assume my gamedir is ‘shadow’

I have

shadow\files\shadow-2.1.ulx
shadow\files\shadowhints.html
shadow\files\GameFiles\checkmark.png
shadow\files\GameFiles\gearclock.jpg
shadow\files\GameFiles\parchment.jpg
shadow\files\GameFiles\question.jpg

This should get copied to:

lectrote\dist\shadow-win32-x64\resources\app\shadow\files\

Right?

Am I allowed to have a sub-folder in the gamedir?

Or should I flatten all of it into the gamedir?

I’m going to conclude that “wrapped” and “unwrapped” are how the zip file is created. “unwrapped” means the zip root is the contents of the dist\win32-ia32 and doesn’t actually have the “dist\win32-ia32” as folders within the zip.

So I have makefile.py working now for a windows build. Most of the problems were the shell commands that assumed bash was in use instead of cmd on Windows. Then the assumption that a ‘zip’ command was built-in, though that was an easy fix. Then the copyfile python command on Windows won’t overwrite a file and will actually cause an error. Then the build stuff was just weird on Windows. Does like cd %s in subprocess. I had to use os.chdir() and do it once for ‘dist’ and once for gamedir. Then unwind that with ‘…\’ twice. Cra-a-azy.

I have Shadow in the Cathedral built, with hints.

If I have time in the near future, all of this really should be moved to gulp tasks. Shouldn’t be too hard and the cross-platform stuff would be much cleaner.

That is great news! Is it only a matter of replacing makedist.py with your version, or are there other changes that must be made?

That’s it, as long as you’ve removed that preinstall line in package.json.

  • Make sure you have your own folder for your story with everything it needs (under the lectrote folder, so lectrote\shadow is what I have)
  • Copy the samplegame files to your folder and make changes accordingly (minor HTML edits).
  • Update the package.json per lectrote docs (mine is shown below for shadow)
  • Make sure you list all extra files (including game file) in lectroteExtraFiles [“file1”,“file2”]
  • npm install --save-dev bestzip
  • npm install --save-dev run-script-os (if you decide to use the preinstall scripts)

build first with
py makedist.py win32

then package
py makedist.py --game yourfoldername win32

This will leave zip files in the dist folder, though you can test it by running the compiled elextron app named yourfoldername.exe in the unzipped folder.

Let me know how it goes…everything is below…

  "scripts": {
    "start": "electron .",
    "preinstall": "run-script-os",
    "preinstall:macos": "if [ -f quixe/LICENSE ]; then echo Quixe already installed; elif [ -d .git ]; then git submodule init; git submodule update; else git clone https://github.com/erkyrath/quixe.git; fi",
    "preinstall:linux": "if [ -f quixe/LICENSE ]; then echo Quixe already installed; elif [ -d .git ]; then git submodule init; git submodule update; else git clone https://github.com/erkyrath/quixe.git; fi",
    "preinstall:win32": "if EXISTS quixe/LICENSE ( echo Quixe already installed) else (if EXISTS .git ( git submodule init; git submodule update; ) else (git clone https://github.com/erkyrath/quixe.git; fi)"
  },

shadow package.json

{
  "name": "shadow",
  "productName": "shadow",
  "version": "2.1.0",
  "lectroteVersion": "2.8.1",
  "description": "The Shadow in the Cathedral",
  "author": "Ian Finely, Jon Ingold",
  "lectrotePackagedGame": "shadow-2.1.ulx",
  "lectroteSoleInterpreter": "glulx",
  "lectroteExtraFiles": ["shadow-2.1.ulx", "shadowhints.html", "checkmark.png", "gearclock.jpg", "parchment.jpg", "question.jpg",
                          "chapter-1.png", "chapter-2.png", "chapter-3.png", "chapter-4.png", "chapter-5.png", "chapter-6.png",
                          "chapter-7.png", "chapter-8.png", "chapter-9.png", "chapter-10.png", "chapter-11.png" ],
  "lectroteCopyright": "Copyright 2017 by Ian Finley & Jon Ingold",
  "main": "main.js",
  "bin": {
    "lectrote": "./cli.sh"
  },
  "scripts": {
    "start": "electron .",
    "preinstall": "run-script-os",
    "preinstall:macos": "if [ -f quixe/LICENSE ]; then echo Quixe already installed; elif [ -d .git ]; then git submodule init; git submodule update; else git clone https://github.com/erkyrath/quixe.git; fi",
    "preinstall:linux": "if [ -f quixe/LICENSE ]; then echo Quixe already installed; elif [ -d .git ]; then git submodule init; git submodule update; else git clone https://github.com/erkyrath/quixe.git; fi",
    "preinstall:win32": "if EXISTS quixe/LICENSE ( echo Quixe already installed) else (if EXISTS .git ( git submodule init; git submodule update; ) else (git clone https://github.com/erkyrath/quixe.git; fi)"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/erkyrath/lectrote.git"
  },
  "keywords": [
    "interactive fiction",
    "interpreter",
    "quixe",
    "glkote",
    "glulx",
    "parchment",
    "zcode",
    "zmachine",
    "ink"
  ],
  "license": "MIT",
  "dependencies": {
    "electron": "1.6.11"
  },
  "devDependencies": {
    "electron-packager": "^8.0.0",
    "run-script-os": "^1.0.2"
  },
  "optionalDependencies": {
    "appdmg": "^0.4.0"
  }
}

makedist.py

#!/usr/bin/env python3

# Usage: python3 makedist.py OR for Windows: py makedist.py [--game yourdir] [win32]
#
# This script copies the working files (everything needed to run Lectrote)
# into prebuilt Electron app packages. Fetch these from
#    https://github.com/atom/electron/releases
# and unzip them into a "dist" directory.

import sys
import os, os.path
import optparse
import shutil
import json
import subprocess

all_packages = [
    'darwin-x64',
    'linux-ia32',
    'linux-x64',
    'win32-ia32',
    'win32-x64',
]

popt = optparse.OptionParser()

popt.add_option('-b', '--build',
                action='store_true', dest='makedist',
                help='build dist directories')
popt.add_option('-z', '--zip',
                action='store_true', dest='makezip',
                help='turn dist directories into zip files')
popt.add_option('-n', '--none',
                action='store_true', dest='makenothing',
                help='do nothing except look at the arguments')
popt.add_option('-g', '--game', '--gamedir',
                action='store', dest='gamedir',
                help='directory for game-specific files')
popt.add_option('-v', '--version',
                action='store', dest='buildversion',
                default='1',
                help='build version (default 1)')

(opts, args) = popt.parse_args()


appfiles = [
    './package.json',
    './main.js',
    './apphooks.js',
    './formats.js',
    './play.html',
    './prefs.html',
    './prefs.js',
    './fonts.js',
    './about.html',
    './if-card.html',
    './if-card.js',
    './fonts.css',
    './play.css',
    './el-glkote.css',
    './font',  # all files
    './icon-128.png',
    './docicon-glulx.ico',
    './docicon-zcode.ico',
    './docicon-hugo.ico',
    './docicon-json.ico',
    './quixe/lib/elkote.min.js',
    './quixe/lib/jquery-1.12.4.min.js',
    './quixe/lib/quixe.min.js',
    './quixe/media/waiting.gif',
    {
        'key': 'ifvms',
        'files': [
            './zplay.html',
            './ifvms/zvm.min.js',
            './ifvms/zvm_dispload.min.js',
            './ifvms/zvm.css',
            './ifvms/package.json',
        ]
    },
    {
        'key': 'emglken',
        'files': [
            './emglkenplay.html',
            './emglken/hugo.js',
            './emglken/hugo-core.js.bin',
            './emglken/hugo-core.js.mem',
            './emglken/git.js',
            './emglken/git-core.js.bin',
            './emglken/git-core.js.mem',
            './emglken/emglken_dispload.min.js',
            './emglken/versions.json',
        ]
    },
    {
        'key': 'inkjs',
        'files': [
            './inkplay.html',
            './inkplay.js',
            './inkjs/ink.min.js',
            './inkjs/ink-130.min.js',
            './inkjs/ink-146.min.js',
            './inkjs/package.json',
        ]
    },
]

rootfiles = [
    './LICENSE',    
    './LICENSES-FONTS.txt',
]

win32_rootfiles = [
    'LICENSE',
    'LICENSES-FONTS.txt',
]

def install(resourcedir, pkg):
    if not os.path.isdir(resourcedir):
        raise Exception('path does not exist: ' + resourcedir)
    appdir = resourcedir
    print('Installing to: ' + appdir)

    soleterp = pkg.get('lectroteSoleInterpreter')

    appfilesused = []
    for val in appfiles:
        if type(val) is dict:
            if soleterp and val['key'] != soleterp:
                continue
            for filename in val['files']:
                appfilesused.append(filename)
        else:
            appfilesused.append(val)
    
    os.makedirs(appdir, exist_ok=True)
    qdir = os.path.join(appdir, 'quixe')
    os.makedirs(qdir, exist_ok=True)
    os.makedirs(os.path.join(qdir, 'lib'), exist_ok=True)
    os.makedirs(os.path.join(qdir, 'media'), exist_ok=True)
    zvmdir = os.path.join(appdir, 'ifvms')
    os.makedirs(zvmdir, exist_ok=True)
    emglkendir = os.path.join(appdir, 'emglken')
    os.makedirs(emglkendir, exist_ok=True)
    inkdir = os.path.join(appdir, 'inkjs')
    os.makedirs(inkdir, exist_ok=True)

    for filename in appfilesused:
        srcfilename = filename
        if opts.gamedir:
            val = os.path.join(opts.gamedir, filename)
            if os.path.exists(val):
                srcfilename = val

        tgtfilename = os.path.join(appdir, filename)
        if not os.path.isdir(filename):
            if not os.path.exists(tgtfilename):
                shutil.copyfile(srcfilename, tgtfilename)
        else:
            subdirname = os.path.join(appdir, filename)
            os.makedirs(subdirname, exist_ok=True)
            for subfile in os.listdir(srcfilename):
                tgtfilename = os.path.join(subdirname, subfile)
                if not os.path.exists(tgtfilename):
                    shutil.copyfile(os.path.join(srcfilename, subfile), tgtfilename)

    extrafiles = pkg.get('lectroteExtraFiles')
    if opts.gamedir and extrafiles:
        gamedir = os.path.basename(opts.gamedir)
        os.makedirs(gamedir, exist_ok=True)
        for filename in extrafiles:
            srcfilename = os.path.join(opts.gamedir, filename)
            if not os.path.isdir(filename):
                tgtfilename = os.path.join(gamedir, filename)
                if not os.path.exists(tgtfilename):
                    shutil.copyfile(srcfilename, tgtfilename)
            else:
                subdirname = os.path.join(gamedir, filename)
                os.makedirs(subdirname, exist_ok=True)
                for subfile in os.listdir(srcfilename):
                    tgtfilename = os.path.join(subdirname, subfile)
                    if not os.path.exists(tgtfilename):
                        shutil.copyfile(os.path.join(srcfilename, subfile), tgtfilename)

def builddir(dir, pack, pkg):
    (platform, dummy, arch) = pack.partition('-')

    cmd = 'node_modules/.bin/electron-packager'

    if platform == 'win32':
        cmd = 'node_modules\.bin\electron-packager'

    args = [
        cmd, product_name, product_name,
        '--app-version', product_version,
        '--build-version', opts.buildversion,
        '--arch='+arch, '--platform='+platform,
        '--out', 'dist',
        '--overwrite'
        ]

    if platform == 'darwin':
        appid = 'com.eblong.lectrote'
        if opts.gamedir:
            appid = pkg.get('lectroteMacAppID')
            if not appid:
                raise Exception('Mac package must set lectroteMacAppID')
            if appid == 'com.eblong.lectrote':
                raise Exception('lectroteMacAppID must not be com.eblong.lectrote')

        iconpath = 'resources/appicon-mac.icns'
        if opts.gamedir and os.path.exists(os.path.join(opts.gamedir, 'resources/appicon-mac.icns')):
            iconpath = os.path.join(opts.gamedir, 'resources/appicon-mac.icns')
        
        args = args + [
            '--app-bundle-id='+appid,
            '--app-category-type=public.app-category.games',
            '--icon='+iconpath,
            '--extra-resource=resources/icon-glulx.icns',
            '--extra-resource=resources/icon-zcode.icns',
            '--extra-resource=resources/icon-hugo.icns',
            '--extra-resource=resources/icon-blorb.icns',
            '--extra-resource=resources/icon-gblorb.icns',
            '--extra-resource=resources/icon-zblorb.icns',
            '--extra-resource=resources/icon-glksave.icns',
            '--extra-resource=resources/icon-glkdata.icns',
            '--extra-resource=resources/icon-json.icns',
            '--extend-info', 'resources/Add-Info.plist',
            ]

    if platform == 'win32':
        iconpath = 'resources/appicon-win.ico'
        if opts.gamedir and os.path.exists(os.path.join(opts.gamedir, 'resources/appicon-win.ico')):
            iconpath = os.path.join(opts.gamedir, 'resources/appicon-win.ico')

        filedesc = 'Interactive Fiction Interpreter'
        if opts.gamedir and pkg.get('description'):
            filedesc = pkg.get('description')

        if not opts.gamedir:
            companyname = 'Zarfhome Software'
        else:
            companyname = pkg.get('lectroteCompanyName')
        if companyname:
            args.append('--win32metadata.CompanyName='+companyname)

        if not opts.gamedir:
            copyright = 'Copyright 2016 by Andrew Plotkin'
        else:
            copyright = pkg.get('lectroteCopyright')
        if copyright:
            args.append('--app-copyright='+copyright)
        
        args = args + [
            '--win32metadata.InternalName='+product_name,
            '--win32metadata.ProductName='+product_name,
            '--win32metadata.OriginalFilename='+product_name+'.exe',
            '--win32metadata.FileDescription='+filedesc,
            '--icon='+iconpath,
            ]

        rootfiles = win32_rootfiles
        
    subprocess.call(args, shell=True)

    for filename in rootfiles:
        shutil.copyfile(filename, os.path.join(dir, filename))
    os.unlink(os.path.join(dir, 'version'))
    
def makezip(dir, zipdir, unwrapped=False):
    prefix = product_name + '-'
    val = os.path.split(dir)[-1]
    if not val.startswith(prefix):
        raise Exception('path does not have the prefix')
    zipfile = product_name + '-' + product_version + '-' + val[len(prefix):]
    zipargs = '-q'
    if 'darwin' in zipfile:
        zipfile = zipfile.replace('darwin', 'macos')
        print('AppDMGing up: %s to %s' % (dir, zipfile))
        subprocess.call('rm -f "dist/%s.dmg"; node_modules/.bin/appdmg resources/pack-dmg-spec.json "dist/%s.dmg"' % (zipfile, zipfile),
                        shell=True)
        return
    print('Zipping up: %s to %s (%s)' % (dir, zipfile, ('unwrapped' if unwrapped else 'wrapped')))
    if unwrapped:
        if 'darwin' in zipfile:
            subprocess.call('cd "%s"; rm -f "../%s.zip"; bestzip "%s" -r "../%s.zip" *' % (dir, zipfile, zipargs, zipfile), shell=True)
        if 'win32' in zipfile:
            os.chdir('dist')
            os.chdir(zipdir)
            tgtfile = '..\%s.zip' % (zipfile)
            if os.path.exists(tgtfile):
                os.remove(tgtfile)
            subprocess.call('bestzip ..\%s.zip .\\' % (zipfile), shell=True)
            os.chdir('..\\')
            os.chdir('..\\')
    else:
        dirls = os.path.split(dir)
        subdir = dirls[-1]
        topdir = os.path.join(*os.path.split(dir)[0:-1])

        if 'darwin' in zipfile:
            subprocess.call('cd "%s"; rm -f "%s.zip"; bestzip "%s" -r "%s.zip" "%s"' % (topdir, zipfile, zipargs, zipfile, subdir), shell=True)

        if 'win32' in zipfile:
            subprocess.call('cd "%s"; del /F "%s.zip"; bestzip "%s" -r "%s.zip" "%s"' % (topdir, zipfile, zipargs, zipfile, subdir), shell=True)

# Start work! First, read the version string out of package.json.

pkgfile = 'package.json'
if opts.gamedir and os.path.exists(os.path.join(opts.gamedir, 'package.json')):
    pkgfile = os.path.join(opts.gamedir, 'package.json')
fl = open(pkgfile)
pkg = json.load(fl)
fl.close()

product_version = pkg['version']
product_name = pkg['productName'];
print('%s version: %s' % (product_name, product_version,))
if product_name != 'Lectrote':
    print('%s version: %s' % ('Lectrote', pkg['lectroteVersion'],))

# Decide what distributions we're working on. ("packages" is a bit overloaded,
# sorry.)

packages = []
if not args:
    packages = all_packages
else:
    for pack in all_packages:
        for arg in args:
            if arg in pack:
                packages.append(pack)
                break

if not packages:
    raise Exception('no packages selected')

if not opts.gamedir:
    os.makedirs('tempapp', exist_ok=True)
    install('tempapp', pkg)

if opts.gamedir:
    os.makedirs(opts.gamedir, exist_ok=True)
    install(opts.gamedir, pkg)

os.makedirs('dist', exist_ok=True)

doall = not (opts.makedist or opts.makezip or opts.makenothing)

if doall or opts.makedist:
    for pack in packages:
        (platform, dummy, arch) = pack.partition('-')

        if platform == 'win32':
            dest = 'dist\%s-%s' % (product_name, pack,)
            builddir(dest, pack, pkg)

        if platform == 'darwin':
            dest = 'dist/%s-%s' % (product_name, pack,)
            builddir(dest, pack, pkg)

if doall or opts.makezip:
    for pack in packages:
        (platform, dummy, arch) = pack.partition('-')

        if platform == 'win32':
            zippath = 'dist\%s-%s' % (product_name, pack,)
            zipdir = '%s-%s' % (product_name, pack,)
            makezip(zippath, zipdir, unwrapped=('win32' in pack))

        if platform == 'darwin':
            dest = 'dist/%s-%s' % (product_name, pack,)
            makezip(dest, '', unwrapped=('win32' in pack))

Thanks for figuring this out, this is really good work.

Indeed. I just stopped looking at it after I got it to work on my Mac, so I’m happy it’s now usable by more people.

Success!

One glitch: though I ran “npm install --save-dev bestzip” successfully (or at least with no error messages), when I run makedist.py I’m getting this error at the very end:

Zipping up: dist\Anchorhead-win32-ia32 to Anchorhead-1.0.0-win32-ia32 (unwrapped) 'bestzip' is not recognized as an internal or external command, operable program or batch file. Zipping up: dist\Anchorhead-win32-x64 to Anchorhead-1.0.0-win32-x64 (unwrapped) 'bestzip' is not recognized as an internal or external command, operable program or batch file.

However, I’m assuming that bestzip’s job is just to zip up everything in the dist directories, which I can certainly do on my own, manually. Is this assumption correct? Is there anything else that bestzip is meant to do (e.g., does it create a self-extracting executable rather than just a ZIP file, or create registry entries, or something like that?)

npm install -g bestzip

sorry - should be global - and you might have to reopen your command window

although you can just zip the dist… folder yourself using the built in windows Send to Compressed Folder right-click menu item

There are some tools to turn an Electron app into a Windows installer. If I poke around the gulp tasks, I might look into that too.

Well creating an installer is easy, once you have the dist folders…

REF: github.com/electron/windows-installer

  • create buildexe.js file based on code snippet below in your /dist folder
    NOTE: The .exe has to match what’s in the dist folder
  • npm install -g electron-winstaller
  • from command line (I’d recommend as Administrator):
    node buildexe.js
  • results will be in dist/release including msi and exe installers (and a nuget installer, but who cares)

As noted in the doc, if you plan to sell your app, you should purchase a code-signing cert and sign the msi/exe before distributing

buildexe.js

var winInstaller = require('electron-winstaller');

resultPromise = winInstaller.createWindowsInstaller({
    appDirectory: './shadow-win32-ia32',
    outputDirectory: './release',
    authors: 'Ian Finley & Jon Ingold',
    exe: 'shadow.exe'
  });

resultPromise.then(() => console.log("It worked!"), (e) => console.log(`No dice: ${e.message}`));