Files
datatools-dev/build/launcher.py
Michael e1f364f010 feat: Tier B operator scaffolding — bundle, copy SoT, posts, emails
Pick up and finish yesterday's cut-off Tier B pass.

- build/: PyInstaller scaffold (datatools.spec + launcher.py +
  hook-streamlit.py + README) — folder-mode bundle, locked
  127.0.0.1, per-OS recipe
- marketing/COPY.md: single source of truth for every customer-facing
  string — landing H1/sub/CTAs, demo CTAs, email subjects, Gumroad
  listing, banned phrases
- marketing/community-posts/: 9 drafts (3 posts × 3 niches:
  bookkeeper, revops, shopify-pet) — story / tip / soft-offer
- marketing/emails/: 18 drafts (Gumroad delivery + 5-touch
  onboarding × 3 niches), per-niche segmentation guidance
- docs/NEXT-STEPS.md: flip 2.2 / 2.4 / 3.1 / 3.4 to done with
  pointers to the new assets; add Phase 0 inventory rows
- .gitignore: narrow `build/` ignore so PyInstaller spec + launcher
  + hooks get tracked, only generated artifacts (build/build/,
  build/__pycache__/, build/dist/) stay ignored

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:04:37 +00:00

139 lines
5.0 KiB
Python

"""DataTools desktop launcher.
This is the entry point PyInstaller wraps for Mac / Windows / Linux
installers. Double-clicking the produced binary boots a local
Streamlit server (``127.0.0.1:<random-free-port>``), opens the user's
default browser at that URL, and keeps the server alive until the
window is closed or the binary is killed.
Why a launcher instead of pointing PyInstaller at ``src/gui/app.py``:
* Streamlit's CLI normally bootstraps the server via the
``streamlit run`` command. PyInstaller-bundled apps can't shell
out to ``streamlit`` because the CLI script lives inside the
bundle. We invoke Streamlit's bootstrap directly via
:func:`streamlit.web.bootstrap.run`.
* A free port has to be picked at runtime — buyers will have other
services running on 8501.
* The "open browser" step is the buyer's only feedback that
something happened; without it they'd see a black terminal flash
on Windows and conclude the app didn't start.
Local-dev equivalent (no installer):
streamlit run src/gui/app.py
"""
from __future__ import annotations
import os
import socket
import sys
import threading
import time
import webbrowser
from pathlib import Path
def _find_free_port(start: int = 8501, span: int = 50) -> int:
"""Return a TCP port that's free on the loopback interface.
Prefer 8501 (Streamlit's traditional default — buyer recognises
the URL from any docs they've read) and fall back to the next
free port in a small range. We don't fall back to OS-allocated
(port=0) because the buyer's URL should look stable across
restarts within one session.
"""
for offset in range(span):
port = start + offset
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
# Last resort: kernel-assigned ephemeral port.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _resolve_app_path() -> Path:
"""Locate ``src/gui/app.py`` whether running from source or a frozen bundle.
PyInstaller's ``onefile`` mode unpacks resources into a temp
directory pointed at by ``sys._MEIPASS``. Bundled mode uses that
directory; source mode walks up from this file.
"""
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
# Frozen: app.py was bundled as a data file (see datatools.spec).
return Path(sys._MEIPASS) / "src" / "gui" / "app.py" # type: ignore[attr-defined]
return Path(__file__).resolve().parent.parent / "src" / "gui" / "app.py"
def _open_browser_when_ready(url: str, delay: float = 1.5) -> None:
"""Open the buyer's default browser to *url* after a short delay.
The delay gives Streamlit's HTTP server time to bind. Without it,
the browser races the server and renders a "couldn't connect"
page that confuses non-technical buyers. 1.5 s is conservative
on slow Windows machines; faster machines will see a brief
blank tab.
"""
def _open() -> None:
time.sleep(delay)
webbrowser.open(url, new=2)
threading.Thread(target=_open, daemon=True).start()
def main() -> int:
"""Boot the local Streamlit server and open the browser."""
app_path = _resolve_app_path()
if not app_path.exists():
sys.stderr.write(
f"DataTools could not find its UI script at {app_path}.\n"
"This is usually a bundle-build error. Re-install or "
"contact support@datatools.app.\n"
)
return 2
port = _find_free_port()
url = f"http://127.0.0.1:{port}/"
# Pre-set Streamlit options the bundle ships locked. ``server.address``
# = 127.0.0.1 enforces "no network exposure" — Streamlit's default
# is 0.0.0.0 which would expose the GUI to the LAN. The privacy
# claim on the landing pages depends on this.
os.environ.setdefault("STREAMLIT_SERVER_ADDRESS", "127.0.0.1")
os.environ.setdefault("STREAMLIT_SERVER_PORT", str(port))
os.environ.setdefault("STREAMLIT_SERVER_HEADLESS", "true")
os.environ.setdefault("STREAMLIT_BROWSER_GATHER_USAGE_STATS", "false")
# Print before opening the browser so the terminal log doesn't
# scroll behind the new browser tab on macOS.
print(f"DataTools is running at {url}")
print("Close this window or press Ctrl+C to stop.")
_open_browser_when_ready(url)
# Streamlit's bootstrap entry point — equivalent to running
# ``streamlit run app.py`` but in-process so PyInstaller's bundled
# interpreter handles it without shelling out to a separate script.
from streamlit.web import bootstrap
bootstrap.run(
str(app_path),
is_hello=False,
args=[],
flag_options={
"server.address": "127.0.0.1",
"server.port": port,
"server.headless": True,
"browser.gatherUsageStats": False,
},
)
return 0
if __name__ == "__main__":
sys.exit(main())