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>
This commit is contained in:
138
build/launcher.py
Normal file
138
build/launcher.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user