Restructure app-desktop as self-contained package, add PyInstaller build

Consolidates everything the desktop app depends on into app-desktop/ so it
packages cleanly as a standalone Windows exe:

- app-desktop-macos/ -> app-desktop/src/ (drop macOS-only naming, app now
  targets Windows via PyInstaller too)
- remote-server/ws_server.py and app-presets/ moved into app-desktop/src/
  (both were exclusive dependencies of main.py/presets.py) — fixes a
  PyInstaller "missing module" error for ws_server that only surfaced once
  packaging was attempted, since its old location wasn't on the analyzer's
  search path
- .venv relocated into app-desktop/src/ alongside the code it serves
- run.py moved into src/, path logic simplified now that it's co-located
  with main.py instead of bridging from the repo root
- Deleted app/, server/ — stale __pycache__-only fossils from prior reorgs
- .gitignore: added missing .venv/ entry (was only covering venv/, a
  different name — the real folder was never actually ignored) and
  app-desktop/build|dist/ for PyInstaller output

presets.py: added a frozen-vs-source branch. Running from source is
unchanged (app-presets/ next to the code). A packaged exe can't write to
Program Files, so it uses %APPDATA%\VirtualController\ instead — standard
Windows convention. First launch on a machine with no AppData presets yet
seeds from presets.json bundled into the exe (PyInstaller --add-data,
baked into VirtualController.spec), so a fresh install starts with real
presets instead of empty; every launch after that only touches AppData.

Verified: exe builds clean (no missing-module warnings), launches with a
real window, and a clean first-launch correctly seeds AppData from the
bundled presets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Paul Lipscomb
2026-07-31 18:50:35 -05:00
parent 60c8255c2d
commit a3e6f6415f
28 changed files with 79 additions and 14 deletions
+2 -1
View File
@@ -41,7 +41,8 @@
"Bash(awk '/int REAPERAPI_LoadAPI/,0' /Users/p4piwabl0/Desktop/projects/virtual-controller-clean/extension-reaper-macos/vendor/reaper-sdk/sdk/reaper_plugin_functions.h)", "Bash(awk '/int REAPERAPI_LoadAPI/,0' /Users/p4piwabl0/Desktop/projects/virtual-controller-clean/extension-reaper-macos/vendor/reaper-sdk/sdk/reaper_plugin_functions.h)",
"Bash(grep -n \"{NULL, NULL}\")", "Bash(grep -n \"{NULL, NULL}\")",
"Bash(ls -la \"/Applications/REAPER.app/Contents/MacOS/\" 2>&1 | head -5 *)", "Bash(ls -la \"/Applications/REAPER.app/Contents/MacOS/\" 2>&1 | head -5 *)",
"Read(//Applications/REAPER.app/Contents/MacOS/**)" "Read(//Applications/REAPER.app/Contents/MacOS/**)",
"Bash(src/.venv/Scripts/python.exe -m PyInstaller --name VirtualController --onedir --windowed --distpath dist --workpath build --specpath . --add-data \"src/app-presets/presets.json;app-presets\" src/main.py)"
] ]
} }
} }
+5
View File
@@ -1,4 +1,5 @@
venv/ venv/
.venv/
__pycache__/ __pycache__/
@@ -6,6 +7,10 @@ __pycache__/
.DS_Store .DS_Store
# PyInstaller output (app-desktop)
app-desktop/build/
app-desktop/dist/
# Xcode # Xcode
*.xcuserstate *.xcuserstate
xcuserdata/ xcuserdata/
+44
View File
@@ -0,0 +1,44 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['src\\main.py'],
pathex=[],
binaries=[],
datas=[('src/app-presets/presets.json', 'app-presets')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='VirtualController',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
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=True,
upx_exclude=[],
name='VirtualController',
)
@@ -9,10 +9,29 @@ from PyQt6.QtWidgets import (
) )
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) import shutil
PRESETS_DIR = os.path.join(BASE_DIR, "app-presets") import sys
PRESETS_FILE = os.path.join(PRESETS_DIR, "presets.json")
os.makedirs(PRESETS_DIR, exist_ok=True) if getattr(sys, "frozen", False):
# Packaged exe: Program Files (or wherever it's installed) is not
# writable by a normal user, so presets live in the per-user AppData
# folder instead — standard Windows convention for app-written data.
PRESETS_DIR = os.path.join(os.environ["APPDATA"], "VirtualController")
PRESETS_FILE = os.path.join(PRESETS_DIR, "presets.json")
os.makedirs(PRESETS_DIR, exist_ok=True)
if not os.path.exists(PRESETS_FILE):
# First launch on this machine: seed from the presets.json bundled
# into the exe (sys._MEIPASS), so a fresh install starts with the
# real presets instead of empty. Only happens once — after this,
# AppData is the only copy that's ever read or written.
_bundled = os.path.join(sys._MEIPASS, "app-presets", "presets.json")
if os.path.exists(_bundled):
shutil.copyfile(_bundled, PRESETS_FILE)
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PRESETS_DIR = os.path.join(BASE_DIR, "app-presets")
PRESETS_FILE = os.path.join(PRESETS_DIR, "presets.json")
os.makedirs(PRESETS_DIR, exist_ok=True)
+5
View File
@@ -0,0 +1,5 @@
import os
import runpy
_here = os.path.dirname(os.path.abspath(__file__))
runpy.run_path(os.path.join(_here, "main.py"), run_name="__main__")
View File
-9
View File
@@ -1,9 +0,0 @@
import sys
import os
_root = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_root, "app-desktop-macos"))
sys.path.insert(0, os.path.join(_root, "remote-server"))
import runpy
runpy.run_path(os.path.join(_root, "app-desktop-macos", "main.py"), run_name="__main__")