diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..17f5887
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,80 @@
+name: Build Avida-ED Onefile
+on:
+ workflow_dispatch:
+ inputs:
+ ed_versions:
+ description: "Comma-separated versions (v3,v4)"
+ default: "v3,v4"
+ required: true
+
+jobs:
+ build:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-22.04, windows-2022, macos-13]
+ ver: [v3, v4]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Fetch assets via docker compose (using URL fallback on mac/win if docker unavailable)
+ shell: bash
+ run: |
+ if command -v docker >/dev/null 2>&1; then
+ docker compose run --rm fetch-${{ matrix.ver }}
+ else
+ MODE=url URL=https://avida-ed.msu.edu/app4/ OUTDIR=./apps/${{ matrix.ver }} bash tools/fetch_assets.sh
+ fi
+
+ - name: Inject webroot
+ run: |
+ rm -rf server-ui/webroot
+ mkdir -p server-ui/webroot
+ rsync -a apps/${{ matrix.ver }}/Avida-ED-Eco/ server-ui/webroot/Avida-ED-Eco/
+
+ - name: Build binary
+ run: |
+ cd server-ui
+ cargo build --release
+
+ - name: Package (Linux AppImage)
+ if: startsWith(matrix.os, 'ubuntu')
+ run: |
+ sudo apt-get update && sudo apt-get install -y imagemagick || true
+ bash packaging/linux/make_appimage.sh ${{ matrix.ver }} server-ui/target/release/avidaed_onefile
+ mv Avida-ED-${{ matrix.ver }}-Linux-x86_64.AppImage Avida-ED-${{ matrix.ver }}-Linux-x86_64.AppImage
+
+ - name: Package (Windows EXE)
+ if: startsWith(matrix.os, 'windows')
+ shell: powershell
+ run: |
+ $WV2 = "$env:RUNNER_TEMP\WebView2Fixed" # pre-cached in your org, or downloaded here
+ # TODO: fetch/unzip WebView2 Fixed into $WV2 if not cached
+ powershell -ExecutionPolicy Bypass -File packaging/windows/make_windows_sfx.ps1 `
+ -Version ${{ matrix.ver }} `
+ -BinPath server-ui/target/release/avidaed_onefile.exe `
+ -WV2Fixed $WV2
+ Move-Item packaging\windows\Avida-ED-${{ matrix.ver }}-Windows.exe .
+
+ - name: Package (macOS .app)
+ if: startsWith(matrix.os, 'macos')
+ run: |
+ bash packaging/mac/make_macos_bundle.sh ${{ matrix.ver }} server-ui/target/release/avidaed_onefile
+ # Optionally make a DMG
+ # brew install create-dmg
+ # create-dmg "Avida-ED-${{ matrix.ver }}.app"
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: Avida-ED-${{ matrix.ver }}-${{ matrix.os }}
+ path: |
+ *.AppImage
+ *.exe
+ Avida-ED-${{ matrix.ver }}.app
+ if-no-files-found: ignore
+
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..75e66ed
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,44 @@
+VER ?= v4 # or v3
+
+.PHONY: fetch-$(VER) inject-$(VER) build-linux build-mac build-win appimage winexe macapp all
+
+fetch-v3:
+ docker compose run --rm fetch-v3
+fetch-v4:
+ docker compose run --rm fetch-v4
+
+inject-$(VER):
+ rm -rf server-ui/webroot
+ mkdir -p server-ui/webroot
+ rsync -a apps/$(VER)/Avida-ED-Eco/ server-ui/webroot/Avida-ED-Eco/
+
+# Build binaries natively on each OS runner
+build-linux: inject-$(VER)
+ cd server-ui && cargo build --release
+
+build-mac: inject-$(VER)
+ cd server-ui && cargo build --release
+
+build-win: inject-$(VER)
+ cd server-ui && cargo build --release
+
+# Package
+appimage: build-linux
+ bash packaging/linux/make_appimage.sh $(VER) server-ui/target/release/avidaed_onefile
+
+winexe: build-win
+ # Example: pass location of WebView2 Fixed runtime and built exe
+ powershell -ExecutionPolicy Bypass -File packaging/windows/make_windows_sfx.ps1 \
+ -Version $(VER) \
+ -BinPath $(CURDIR)/server-ui/target/release/avidaed_onefile.exe \
+ -WV2Fixed "C:\SDKs\WebView2.FixedRuntime"
+
+macapp: build-mac
+ bash packaging/mac/make_macos_bundle.sh $(VER) server-ui/target/release/avidaed_onefile
+
+all:
+ @echo "Targets:"
+ @echo " make fetch-v3 | fetch-v4"
+ @echo " make build-linux | build-mac | build-win"
+ @echo " make appimage | winexe | macapp"
+
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..75acba3
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,21 @@
+version: "3.8"
+services:
+ fetch-v3:
+ build: { context: ./tools, dockerfile: Dockerfile.fetch }
+ environment:
+ MODE: "docker" # or "url"
+ ED_VER: "v3"
+ OUTDIR: "/out"
+ volumes:
+ - ./apps/v3:/out
+ # If MODE=url, also pass URL as env
+
+ fetch-v4:
+ build: { context: ./tools, dockerfile: Dockerfile.fetch }
+ environment:
+ MODE: "docker"
+ ED_VER: "v4"
+ OUTDIR: "/out"
+ volumes:
+ - ./apps/v4:/out
+
diff --git a/packaging/linux/Apprun b/packaging/linux/Apprun
new file mode 100644
index 0000000..e0202d7
--- /dev/null
+++ b/packaging/linux/Apprun
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+HERE="$(dirname "$(readlink -f "$0")")"
+exec "$HERE/usr/bin/avidaed_onefile"
+
diff --git a/packaging/linux/Dockerfile.appimage b/packaging/linux/Dockerfile.appimage
new file mode 100644
index 0000000..cb83deb
--- /dev/null
+++ b/packaging/linux/Dockerfile.appimage
@@ -0,0 +1,10 @@
+FROM ubuntu:22.04
+RUN apt-get update && apt-get install -y \
+ build-essential curl git libgtk-3-dev libayatana-appindicator3-dev \
+ libwebkit2gtk-4.0-dev pkg-config cmake patchelf desktop-file-utils \
+ && rm -rf /var/lib/apt/lists/*
+# appimagetool
+RUN curl -L https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage -o /usr/local/bin/appimagetool \
+ && chmod +x /usr/local/bin/appimagetool
+WORKDIR /work
+
diff --git a/packaging/linux/desktop/avidaed.desktop b/packaging/linux/desktop/avidaed.desktop
new file mode 100644
index 0000000..1b120eb
--- /dev/null
+++ b/packaging/linux/desktop/avidaed.desktop
@@ -0,0 +1,7 @@
+[Desktop Entry]
+Name=Avida-ED
+Exec=AppRun
+Type=Application
+Icon=avidaed
+Categories=Education;Science;
+
diff --git a/packaging/linux/make_appimage.sh b/packaging/linux/make_appimage.sh
new file mode 100644
index 0000000..a24c132
--- /dev/null
+++ b/packaging/linux/make_appimage.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+VER="${1:?version folder, e.g., v4}"
+BINPATH="${2:-../..//server-ui/target/release/avidaed_onefile}"
+
+APPDIR="AvidaED-${VER}.AppDir"
+rm -rf "$APPDIR"; mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/share/icons/hicolor/256x256/apps"
+cp "$BINPATH" "$APPDIR/usr/bin/avidaed_onefile"
+cp "$(dirname "$0")/AppRun" "$APPDIR/AppRun"
+chmod +x "$APPDIR/AppRun"
+cp "$(dirname "$0")/desktop/avidaed.desktop" "$APPDIR/avidaed.desktop"
+
+# placeholder icon; replace with your PNG
+convert -size 256x256 xc:white "$APPDIR/usr/share/icons/hicolor/256x256/apps/avidaed.png" 2>/dev/null || true
+
+appimagetool "$APPDIR" "Avida-ED-${VER}-Linux-x86_64.AppImage"
+echo "Wrote Avida-ED-${VER}-Linux-x86_64.AppImage"
+
diff --git a/packaging/mac/info.plist.tmpl b/packaging/mac/info.plist.tmpl
new file mode 100644
index 0000000..31b8070
--- /dev/null
+++ b/packaging/mac/info.plist.tmpl
@@ -0,0 +1,15 @@
+
+
+
+
+ CFBundleNameAvida-ED
+ CFBundleDisplayNameAvida-ED
+ CFBundleIdentifierorg.avidaed.{{VER}}
+ CFBundleVersion1.0
+ CFBundleShortVersionString1.0
+ CFBundleExecutableAvida-ED
+ LSMinimumSystemVersion10.13
+ NSHighResolutionCapable
+
+
+
diff --git a/packaging/mac/make_macos_bundle.sh b/packaging/mac/make_macos_bundle.sh
new file mode 100644
index 0000000..0cabe13
--- /dev/null
+++ b/packaging/mac/make_macos_bundle.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+set -euo pipefail
+VER="${1:?v3|v4}"
+BINPATH="${2:-../../server-ui/target/release/avidaed_onefile}"
+APP="Avida-ED-${VER}.app"
+rm -rf "$APP"
+mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
+cp "$BINPATH" "$APP/Contents/MacOS/Avida-ED"
+chmod +x "$APP/Contents/MacOS/Avida-ED"
+# Info.plist
+sed "s/{{VER}}/${VER}/g" "$(dirname "$0")/Info.plist.tmpl" > "$APP/Contents/Info.plist"
+# Icon placeholder
+sips -s format icns /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns --out "$APP/Contents/Resources/AppIcon.icns" >/dev/null 2>&1 || true
+echo "Wrote $APP (unsigned)."
+
diff --git a/packaging/windows/_make_windows_sfx.ps1 b/packaging/windows/_make_windows_sfx.ps1
new file mode 100644
index 0000000..247c1ba
--- /dev/null
+++ b/packaging/windows/_make_windows_sfx.ps1
@@ -0,0 +1,32 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Version, # v3 or v4
+ [Parameter(Mandatory=$true)][string]$BinPath, # path to target\release\avidaed_onefile.exe
+ [Parameter(Mandatory=$true)][string]$WV2Fixed # path to WebView2 Fixed Runtime folder
+)
+$Work = Join-Path $PSScriptRoot "payload_$Version"
+Remove-Item $Work -Recurse -Force -ErrorAction SilentlyContinue
+New-Item -ItemType Directory -Force -Path $Work | Out-Null
+Copy-Item $BinPath -Destination (Join-Path $Work "avidaed_onefile.exe")
+Copy-Item $WV2Fixed -Destination (Join-Path $Work "WebView2Fixed") -Recurse
+
+# launcher
+$runbat = @"
+@echo off
+setlocal
+set WEBVIEW2_BROWSER_EXECUTABLE_FOLDER=%~dp0WebView2Fixed
+start "" "%~dp0avidaed_onefile.exe"
+"@
+$runbat | Out-File -Encoding ASCII (Join-Path $Work "run.bat")
+
+# create 7z archive
+$SevenZip = "C:\Program Files\7-Zip\7z.exe"
+& $SevenZip a -t7z (Join-Path $PSScriptRoot "payload_$Version.7z") "$Work\*"
+
+# concatenate SFX + config + archive -> one EXE
+$Sfx = Join-Path $PSScriptRoot "7z.sfx"
+$Cfg = Join-Path $PSScriptRoot "config.txt"
+$Out = Join-Path $PSScriptRoot ("Avida-ED-$Version-Windows.exe")
+Get-Content $Sfx -Encoding Byte, $Cfg -Encoding Byte, (Join-Path $PSScriptRoot "payload_$Version.7z") -Encoding Byte |
+ Set-Content $Out -Encoding Byte
+Write-Host "Wrote $Out"
+
diff --git a/packaging/windows/config.txt b/packaging/windows/config.txt
new file mode 100644
index 0000000..4790f92
--- /dev/null
+++ b/packaging/windows/config.txt
@@ -0,0 +1,9 @@
+;!@Install@!UTF-8!
+Title="Avida-ED"
+BeginPrompt="Install and run Avida-ED?"
+RunProgram="run.bat"
+; extract to temp folder
+ExtractTitle="Avida-ED"
+GUIMode="2"
+;!@InstallEnd@!
+
diff --git a/server-ui/Cargo.toml b/server-ui/Cargo.toml
new file mode 100644
index 0000000..ed47efd
--- /dev/null
+++ b/server-ui/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "avidaed_onefile"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+wry = "0.39" # WebView wrapper: WebView2/WKWebView/WebKitGTK
+tiny-http = "0.12" # tiny static server
+include_dir = "0.7" # embed webroot into binary
+mime_guess = "2.0"
+once_cell = "1.19"
+anyhow = "1.0"
+
+[profile.release]
+lto = true
+codegen-units = 1
+strip = "symbols"
+
diff --git a/server-ui/build.rs b/server-ui/build.rs
new file mode 100644
index 0000000..caa586f
--- /dev/null
+++ b/server-ui/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ println!("cargo:rerun-if-changed=webroot");
+}
diff --git a/server-ui/src/main.rs b/server-ui/src/main.rs
new file mode 100644
index 0000000..1d0dbe5
--- /dev/null
+++ b/server-ui/src/main.rs
@@ -0,0 +1,63 @@
+use include_dir::{include_dir, Dir};
+use tiny_http::{Request, Response, Method};
+use wry::{
+ application::event::{Event, StartCause, WindowEvent},
+ application::event_loop::{ControlFlow, EventLoop},
+ application::window::WindowBuilder,
+ webview::WebViewBuilder
+};
+use std::{net::TcpListener, thread, time::Duration};
+use anyhow::Result;
+
+static WEBROOT: Dir = include_dir!("$CARGO_MANIFEST_DIR/webroot");
+static DEFAULT_PATH: &str = "/Avida-ED-Eco/index.html";
+
+fn mime(p: &str)->&'static str{
+ if p.ends_with(".wasm"){return "application/wasm";}
+ if p.ends_with(".js"){return "application/javascript";}
+ if p.ends_with(".css"){return "text/css";}
+ if p.ends_with(".html") || p.ends_with(".htm"){return "text/html; charset=utf-8";}
+ mime_guess::from_path(p).first_raw().unwrap_or("application/octet-stream")
+}
+fn serve(mut req:Request){
+ if req.method()!=&Method::Get && req.method()!=&Method::Head{
+ let _=req.respond(Response::from_string("Method Not Allowed").with_status_code(405));return;
+ }
+ let mut path=req.url().to_string();
+ if let Some(i)=path.find('?'){path.truncate(i);}
+ if path=="/"{path=DEFAULT_PATH.to_string();}
+ let fpath=path.trim_start_matches('/');
+ if let Some(f)=WEBROOT.get_file(fpath){
+ let mut resp=Response::from_data(f.contents().to_vec());
+ let _=resp.add_header(tiny_http::Header::from_bytes(b"Content-Type", mime(&path)).unwrap());
+ let _=resp.add_header(tiny_http::Header::from_bytes(b"Cache-Control", b"no-store").unwrap());
+ let _=req.respond(resp);
+ }else{
+ let _=req.respond(Response::from_string("Not Found").with_status_code(404));
+ }
+}
+fn main()->Result<()>{
+ let listener=TcpListener::bind(("127.0.0.1",0))?; listener.set_nonblocking(true)?;
+ let port=listener.local_addr()?.port(); let srv=tiny_http::Server::from_tcp(listener)?;
+ std::thread::spawn(move||{
+ loop{
+ match srv.recv_timeout(Duration::from_millis(200)){
+ Ok(Some(r))=>serve(r), Ok(None)=>continue, Err(_)=>break
+ }
+ }
+ });
+ let url=format!("http://127.0.0.1:{port}{DEFAULT_PATH}");
+ let event_loop=EventLoop::new()?;
+ let window=WindowBuilder::new()
+ .with_title("Avida-ED")
+ .with_inner_size(wry::application::dpi::LogicalSize::new(1280.0,800.0))
+ .build(&event_loop)?;
+ let _wv=WebViewBuilder::new(&window)?.with_url(&url)?.build()?;
+ event_loop.run(move|e,_,cf|{
+ *cf=wry::application::event_loop::ControlFlow::Wait;
+ if let Event::WindowEvent{event:WindowEvent::CloseRequested,..}=e{*cf=wry::application::event_loop::ControlFlow::Exit;}
+ if let Event::NewEvents(StartCause::Init)=e{}
+ })?;
+ Ok(())
+}
+
diff --git a/tools/Dockerfile.fetch b/tools/Dockerfile.fetch
new file mode 100644
index 0000000..4b505cb
--- /dev/null
+++ b/tools/Dockerfile.fetch
@@ -0,0 +1,5 @@
+FROM alpine:3.20
+RUN apk add --no-cache ca-certificates curl bash rsync
+WORKDIR /work
+COPY fetch_assets.sh /work/fetch_assets.sh
+ENTRYPOINT ["/work/fetch_assets.sh"]
diff --git a/tools/fetch_assets.sh b/tools/fetch_assets.sh
new file mode 100644
index 0000000..89e71fe
--- /dev/null
+++ b/tools/fetch_assets.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+set -euo pipefail
+MODE="${MODE:-docker}" # docker|url
+ED_VER="${ED_VER:-v4}" # v3|v4
+OUTDIR="${OUTDIR:-/out}"
+
+mkdir -p "$OUTDIR"
+case "$MODE" in
+ docker)
+ # 1) Start your aed-docker container and copy its served webroot
+ # Expectation: container serves Avida-ED-Eco at /usr/share/nginx/html/Avida-ED-Eco
+ CID="$(docker create --name aedtmp_$ED_VER welsberr/aed-docker:$ED_VER)"
+ trap 'docker rm -f aedtmp_'"$ED_VER"' >/dev/null 2>&1 || true' EXIT
+ docker cp "aedtmp_$ED_VER:/usr/share/nginx/html/." "$OUTDIR/"
+ ;;
+ url)
+ URL="${URL:-https://avida-ed.msu.edu/app4/}"
+ apk add --no-cache wget >/dev/null 2>&1 || true
+ wget --recursive --no-parent --page-requisites --adjust-extension \
+ --compression=auto --convert-links --timestamping \
+ --directory-prefix "$OUTDIR" \
+ "$URL"
+ # normalize into $OUTDIR/Avida-ED-Eco as needed
+ if [ ! -d "$OUTDIR/Avida-ED-Eco" ]; then
+ SUB="$(find "$OUTDIR" -type f -name index.html | head -n1)"
+ [ -n "$SUB" ] && rsync -a "$(dirname "$SUB")"/ "$OUTDIR/Avida-ED-Eco"/
+ fi
+ ;;
+ *) echo "Unknown MODE=$MODE" >&2; exit 1;;
+esac
+
+echo "Assets fetched to $OUTDIR"
diff --git a/tools/inject_webroot.rs b/tools/inject_webroot.rs
new file mode 100644
index 0000000..1e867ca
--- /dev/null
+++ b/tools/inject_webroot.rs
@@ -0,0 +1,10 @@
+use std::{env, fs, path::Path};
+fn main() {
+ let args:Vec=env::args().collect();
+ if args.len()!=3{eprintln!("usage: inject_webroot "); std::process::exit(2);}
+ let (src,dst)=(&args[1],&args[2]);
+ if Path::new(dst).exists(){fs::remove_dir_all(dst).ok();}
+ fs::create_dir_all(dst).unwrap();
+ fs_extra::dir::copy(src,dst,&fs_extra::dir::CopyOptions{overwrite:true,copy_inside:true, ..Default::default()}).unwrap();
+}
+