Skip to content

Commit

Permalink
💻 Offline Hedy for Windows (#5116)
Browse files Browse the repository at this point in the history
This adds a build for a standalone Hedy package on Windows.

The end result will be a directory with an executable and some data files hidden in a directory called `_internal`. It will use the dev database in JSON format.

The build is triggered with the (hidden) doit task `doit run _offline`; this also adds a GitHub Workflows script that performs the build.
  • Loading branch information
rix0rrr authored Feb 15, 2024
1 parent 16f9ac4 commit 68c3a5c
Show file tree
Hide file tree
Showing 14 changed files with 381 additions and 37 deletions.
59 changes: 59 additions & 0 deletions .github/workflows/build-offline.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Build offline Hedy
on:
# Can be run on-demand
workflow_dispatch: {}

# Runs when 'deploy to prod' runs
workflow_run:
workflows: ["Deploy to hedy.org"]
types: [requested]
branches:
- 'main'

jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.9
cache: 'pip'
- name: Set up NodeJS 18
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: 'Install npx'
run: npm install -g npx
- run: pip install -r requirements.txt
name: 'Python requirements'
- run: doit run _offline
- name: Smoke test the build
run: cd dist && offlinehedy/run-hedy-server --smoketest

- uses: fregante/daily-version-action@v2
name: Create tag if necessary
id: daily-version

- name: Create zip file
# Because we're on Windows
run: |
cd dist/offlinehedy && Compress-Archive -Path . -Destination ../../offlinehedy-${{ steps.daily-version.outputs.version }}.zip
- if: steps.daily-version.outputs.created
name: Create Release
uses: shogo82148/actions-create-release@v1
id: create_release
with:
tag_name: ${{ steps.daily-version.outputs.version }}
generate_release_notes: true

- name: Upload Assets
if: steps.daily-version.outputs.created
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: '*.zip'
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ MANIFEST
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
Expand Down Expand Up @@ -179,4 +178,5 @@ grammars-Total/*.lark
.test-cache

.doit.db
.doit.db.*
.doit.db.*
dist/
56 changes: 56 additions & 0 deletions OFFLINE_README.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
_ _ _
| | | | | |
| |__| | ___ __| |_ _
| __ |/ _ \/ _` | | | |
| | | | __/ (_| | |_| |
|_| |_|\___|\__,_|\__, |
__/ |
o f f l i n e |___/

Welcome to offline Hedy! You can use offline Hedy to run Hedy on your own
computer, and it can be used by anyone on the same network.


For you
=======

When you first start up offline Hedy on a Windows computer, two things can
happen:

- Windows Firewall will ask you whether or not to allow network connections.
You should click "Allow".
- Windows Defender may say that Hedy is a dangerous program and ask you whether
or not it should be run. You should click "More Info" and then "Run Anyway".

You can create a teacher account for yourself by visiting the following link
and then clicking "Create Account".

http://localhost/invite/newteacher

You can also use one of the built-in accounts, which is named "teacher1"
with password "123456".


For students
============

When Hedy starts up, it will print a web address made from numbers. Your students
should type this address into the address bar of their browser.

The address will look something like this, but it will be different on every
computer. It can also change every time your computer starts up:

http://192.168.31.13/


Upgrading to a newer version
============================

All your data and your student's data is stored in the file `database.json`
that you will see in the program's directory. When you download a newer version
of Offline Hedy it will come with its own empty database. To keep all programs
around, you can copy over the `database.json` file from the old version to the
new version of Hedy.

To keep it simple, you can also start fresh and use the newer version only for a
different class or different year.
101 changes: 93 additions & 8 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,9 +371,14 @@ def before_request_https():

Compress(app)
Commonmark(app)
parse_logger = s3_logger.S3ParseLogger.from_env_vars()
querylog.LOG_QUEUE.set_transmitter(
aws_helpers.s3_querylog_transmitter_from_env())

# We don't need to log in offline mode
if utils.is_offline_mode():
parse_logger = s3_logger.NullLogger()
else:
parse_logger = s3_logger.S3ParseLogger.from_env_vars()
querylog.LOG_QUEUE.set_transmitter(
aws_helpers.s3_querylog_transmitter_from_env())


@app.before_request
Expand Down Expand Up @@ -2710,14 +2715,91 @@ def split_at(n, xs):
return xs[:n], xs[n:]


def on_offline_mode():
"""Prepare for running in offline mode."""
# We are running in a standalone build made using pyinstaller.
# cd to the directory that has the data files, disable debug mode, and
# use port 80 (unless overridden).
# There will be a standard teacher invite code that everyone can use
# by going to `http://localhost/invite/newteacher`.
os.chdir(utils.offline_data_dir())
config['port'] = int(os.environ.get('PORT', 80))
if not os.getenv('TEACHER_INVITE_CODES'):
os.environ['TEACHER_INVITE_CODES'] = 'newteacher'
utils.set_debug_mode(False)

# Disable logging, so Werkzeug doesn't log all requests and tell users with big red
# letters they're running a non-production server.
# from werkzeug import serving
# def do_nothing(*args, **kwargs): pass
# serving.WSGIRequestHandler.log_request = do_nothing
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)

# Get our IP addresses so we can print a helpful hint
import socket
ip_addresses = [addr[4][0] for addr in socket.getaddrinfo(
socket.gethostname(), None, socket.AF_INET, socket.SOCK_STREAM)]
ip_addresses = [i for i in ip_addresses if i != '127.0.0.1']

from colorama import colorama_text, Fore, Back, Style
g = Fore.GREEN
lines = [
('', ''),
('', ''),
(g, r' _ _ _ '),
(g, r'| | | | | | '),
(g, r'| |__| | ___ __| |_ _ '),
(g, r'| __ |/ _ \/ _` | | | |'),
(g, r'| | | | __/ (_| | |_| |'),
(g, r'|_| |_|\___|\__,_|\__, |'),
(g, r' __/ |'),
(g, r' o f f l i n e |___/ '),
('', ''),
('', 'Use a web browser to visit the following website:'),
('', ''),
*[(Fore.BLUE, f' http://{ip}/') for ip in ip_addresses],
('', ''),
('', ''),
]
# This is necessary to make ANSI color codes work on Windows.
# Init and deinit so we don't mess with Werkzeug's use of this library later on.
with colorama_text():
for style, text in lines:
print(Back.WHITE + Fore.BLACK + ''.ljust(10) + style + text.ljust(60) + Style.RESET_ALL)

# We have this option for testing the offline build. A lot of modules read
# files upon import, and those happen before the offline build 'cd' we do
# here and need to be written to use __file__. During the offline build,
# we want to run the actual code to see that nobody added file accesses that
# crash, but we don't actually want to start the server.
smoke_test = '--smoketest' in sys.argv
if smoke_test:
sys.exit(0)


if __name__ == '__main__':
# Start the server on a developer machine. Flask is initialized in DEBUG mode, so it
# hot-reloads files. We also flip our own internal "debug mode" flag to True, so our
# own file loading routines also hot-reload.
utils.set_debug_mode(not os.getenv('NO_DEBUG_MODE'))
no_debug_mode_requested = os.getenv('NO_DEBUG_MODE')
utils.set_debug_mode(not no_debug_mode_requested)

# For local debugging, fetch all static files on every request
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = None
if utils.is_offline_mode():
on_offline_mode()

# Set some default environment variables for development mode
env_defaults = dict(
BASE_URL=f"http://localhost:{config['port']}/",
ADMIN_USER="admin",
)
for key, value in env_defaults.items():
if key not in os.environ:
os.environ[key] = value

if utils.is_debug_mode():
# For local debugging, fetch all static files on every request
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = None

# If we are running in a Python debugger, don't use flasks reload mode. It creates
# subprocesses which make debugging harder.
Expand All @@ -2732,9 +2814,12 @@ def split_at(n, xs):
start_snapshot = tracemalloc.take_snapshot()

on_server_start()
logger.debug('app starting in debug mode')
debug = utils.is_debug_mode() and not (is_in_debugger or profile_memory)
if debug:
logger.debug('app starting in debug mode')

# Threaded option enables multiple instances for multiple user access support
app.run(threaded=True, debug=not is_in_debugger and not profile_memory,
app.run(threaded=True, debug=debug,
port=config['port'], host="0.0.0.0")

# See `Procfile` for how the server is started on Heroku.
Expand Down
75 changes: 75 additions & 0 deletions app.spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#----------------------------------------------------------
# PyInstaller configuration file
#
# This file controls how we build a standalone distribution of
# Hedy that can run in environments where Internet access might
# be spotty.
#----------------------------------------------------------
# -*- mode: python ; coding: utf-8 -*-
from os import path
import sys

dirname = 'offlinehedy'
appname = 'run-hedy-server'

# Find the venv directory. We need to be able to pass this to
# pyinstaller, otherwise it will not bundle the libraries we installed
# from the venv.
venv_dir = [p for p in sys.path if 'site-packages' in p][0]


data_files = [
# Files
('README.md', '.'),
('static_babel_content.json', '.'),

# Folders
('content', 'content'),
('grammars', 'grammars'),
('grammars-Total', 'grammars-Total'),
('prefixes', 'prefixes'),
('static', 'static'),
('templates', 'templates'),
('translations', 'translations'),
]

a = Analysis(
['app.py'],
pathex=[venv_dir],
binaries=[],
datas=data_files,
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
)
pyz = PYZ(a.pure)

exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name=appname,
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name=dirname,
)
Loading

0 comments on commit 68c3a5c

Please sign in to comment.