143 real places across three regions, built once in Python and shipped to five formats — and the packaging work turned out to be harder than the app.
Hong Kong Tour is a guide to 143 places in Hong Kong — Hong Kong Island, Kowloon and the New Territories — split into 27 neighbourhoods. Every entry carries a real photograph of the place, a rating, a description, practical tips, a location and (where available) a map link. Search and fifteen category filters sit on top.
The interesting part is not the content, it is the delivery. One Python core — data, launcher, HTML interface — is packaged as five installable archives (macOS .dmg, Windows .exe, Linux .deb, portable .zip, Android .apk) plus an iOS/iPadOS Xcode project and a server-less browser build. The same code renders all of them; only the shell around it differs.
Five separate bugs came out of that packaging, and every one of them produced a file that looked plausible and was actually broken. They are documented below with root causes, because they are the reusable part of this project.
Measured from the shipped build (data.json and dist/), not from memory.
| Content | Value |
|---|---|
| Spots | 143 |
| Regions | 3 |
| Neighbourhoods / areas | 27 (8 / 7 / 12) |
| Spots per region | 38 / 32 / 73 |
| Categories in use | 15 |
| Spots with a photo | 143 / 143 |
| Spots with rating, tips, location | 143 / 143 |
| Spots with a map link | 102 / 143 |
| Ratings | 3.5 – 4.9 (15 values) |
| Descriptions | 41,678 chars (avg 291) |
| Tips | avg 162 chars |
| Build | Size | Files |
|---|---|---|
Data (hk_data.py) | 136,820 B · 1,849 lines | 1 |
Interface (index.html) | 22,331 B · 657 lines | 1 |
| Launcher | 5,911 B · 147 lines | 1 |
| Build tooling | 885 lines across 5 scripts | 5 |
| Source photos | 300 MB | 268 |
| Browser build | 28 MB | 170 (166 photos) |
data.json | 111,088 B | 1 |
| Installer | Exact bytes | Card label |
|---|---|---|
macOS .dmg | 309,378,949 | 295 MB |
Windows .exe | 306,419,009 | 292 MB |
Linux .deb | 306,294,892 | 292 MB |
Portable .zip | 306,139,800 | 292 MB |
Android .apk | 2,425,148 | 2.3 MB |
Every desktop archive is roughly 300 MB while the whole dataset is 117 KB. That ratio is the honest headline of the project: the app is tiny, the runtime it needs is not. Bundling CPython plus pywebview plus a WebKit/WebView shell accounts for essentially all of it.
One nested dictionary: region → area → spots. Regions carry their own name, Chinese name, blurb, hero image and accent colour; areas group spots into walkable neighbourhoods.
ATTRACTIONS = {
"hong_kong_island": {
"name": "香港島 Hong Kong Island", "name_cn": "香港島",
"description": "...", "image": "...", "color": "#1f4bd8",
"central": {"name": "中環 Central", "spots": [ {...}, ... ]},
}, ...
}
# each spot
{"name": "中環 Central", "name_cn": "中環", "category": "neighbourhood",
"rating": 4.6, "description": "...", "tips": "...",
"location": "22.2819,114.1582", "google_maps": "...",
"image": "images/929d3b56ae67c1a2.jpg"}
nameThe spot names embed their Chinese form ("中環 Central") while name_cn holds the Chinese alone. The interface renders both, which originally printed 中環 Central 中環 on every card — a visible bug. The fix is a shared guard rather than a second field:
function cnSuffix(name, cn) {
return (cn && !name.includes(cn)) ? cn : ''; // '' when name already carries it
}
Used for both the area header and the spot card. This is the kind of bug that only shows up in a build where CJK actually renders, which is why the automated test now asserts the subtitle never repeats the title.
| Category | Spots | Category | Spots |
|---|---|---|---|
| scenic | 23 | museum | 7 |
| landmark | 19 | temple | 6 |
| neighbourhood | 19 | arts_culture | 4 |
| hiking | 16 | food | 2 |
| beach | 14 | nightlife | 1 |
| market | 12 | entertainment | 1 |
| park | 10 | no category | 1 |
| shopping | 8 |
Slugs are singular (beach, not beaches), and the human labels come from a single CAT_LABELS map that feeds both the filter chips and the tags on each card — so a chip can never say "Arts & Culture" while its card says arts_culture.
The launcher starts a threaded HTTP server on port 8570 and points a native webview at it. Everything platform-specific lives in the shell around that, never in the app.
| Target | Shell | How it is produced |
|---|---|---|
macOS .app / .dmg | pywebview → WKWebView | App bundle + launcher script, ad-hoc signed, packed with hdiutil |
Windows .exe | pywebview → Edge WebView2 | 7-Zip SFX self-extractor around the portable tree |
Linux .deb | pywebview → WebKitGTK | ar archive written byte-by-byte by make_deb.py |
Portable .zip | pywebview | Same tree, plus .command / .bat helpers |
Android .apk | WebView wrapper | Gradle + Android SDK, assets = the same HTML + data |
| iOS / iPadOS | SwiftUI + WKWebView | Xcode project generated by gen_ios_project.py |
| Browser | none | Static build: data.json + local search index |
The Android and iOS builds never touch Python: they ship the HTML, CSS, JS and images, and the data arrives through the same JSON the server would have served. That keeps the APK at 2.3 MB while the desktop builds carry a whole interpreter.
Each of these produced an artifact that passed a naive check. Each is a macOS-toolchain or hosting-limit problem, not an app problem.
.deb was 96 bytesCause. macOS ar is BSD ar: it prepends a __.SYMDEF / ranlib symbol table and writes BSD-style member headers. dpkg expects GNU ar. It parsed the file, found no valid members, and the archive ended up as a 96-byte stub.
Fix. build/make_deb.py writes the ar archive by hand — global magic, GNU member headers, control members stored as ./control and ./md5sums (no DEBIAN/ prefix inside the member name). Never use ar here.
.exe was a Mac binaryCause. The SFX was built from 7zCon.sfx as shipped by Homebrew, which is a Mach-O stub, not a Windows PE. Concatenating a Mach-O stub with a payload yields a file that still says .exe.
Fix. Pull real Windows PE stubs (7zS.sfx, 7zS2con.sfx) from the 7z920_extra package into build/sfx/, and verify the result: verify_windows_exe.py checks the MZ DOS header and the PE\0\0 signature at the e_lfanew offset before the file is allowed into dist/.
Cause. Finder launches an .app with a minimal PATH that does not include Homebrew or pyenv, so the launcher's python3 resolved to a Python without pywebview. The process died before a window could exist.
Fix. Probe by import, not by path: the launcher walks a candidate list and takes the first interpreter where python3 -c "import webview" succeeds. A path existing is not evidence the module does.
Cause. Pages hard-caps a single asset at 25 MiB, and the deploy script prunes oversized files (find … -size +25600k -delete) before upload. A 300 MB installer simply disappears from the bundle with no error.
Fix. Keep installers out of the Pages bundle entirely.
Cause. The repo tree rejects any file over 100 MB; git push fails outright. Pages rejects anything over 25 MiB. Both limits are hard.
Fix. Publish the archives as GitHub release assets (2 GB per-asset limit, not part of the tree) and stream them through a small Cloudflare Worker at /files/hongkong-tour/…. The Worker forwards Range in both directions instead of buffering, so the download stays same-origin on isaac1804.com, hides the GitHub URL, and resumes when the connection drops — which matters on a school network.
The desktop app talks to a local server (/api/region, /api/search, /static/images/…). On a static host there is no server, so build/build_web.py removes it rather than emulating it:
/api/region response to data.json (111,088 B);sips -Z 1200 -s formatOptions 75 — 300 MB of source images become 28 MB of shipped ones;loadData() had populated the data — so search returned nothing. Now the index is built lazily on first search.goToSpot(spot.region_key, …), but the flattened index never set region/region_key, so jumping to a result threw. Now both are attached when each spot is indexed.Claims here are backed by the check that produced them, not by "it deployed".
| Claim | Evidence |
|---|---|
| All six packs are valid | verify_all_packs.py → 56/56 checks: DMG mounts and the app inside launches, .deb parses with correct members, .exe has an MZ+PE header, ZIP structure and helpers, APK signature and 143 spots in assets |
| Every spot has a photo | data.json → 143/143, and the APK's assets → 143/143 |
| The static demo works | Playwright: 15/15 — search, jump-to-result, category chips, no CJK duplication, no 404s, no JS errors |
| Installers download correctly | Full 309,378,949 B pull from the live URL → SHA-256 identical to the local build |
| Downloads resume | Windows .exe cut at 125,829,120 B then resumed → final file SHA-256 identical; all five serve Accept-Ranges: bytes |
| The card works | Playwright on the live site: 16/16 — all actions render, clicking a download fires the same-origin URL |
| The mirror is standalone | Playwright against the mirror: 11/11 — 3 regions / 27 areas / 143 spots, search, photos decode, no 404s |
Two of those checks exist specifically because a green-looking result lied earlier: a page-level request listener cannot see a window.open download (it reports "no request" for a working button), and a full download is the only thing that detects a streaming proxy that truncates while returning correct headers.
category field, so it is invisible to every filter chip — it only appears in the unfiltered list and in search. One line of data fixes it..ipa needs full Xcode plus a signing identity. This machine has Command Line Tools only, so no iOS build is claimed.All five installers are on the portfolio card's download buttons, served same-origin and resumable. Source lives at /Applications/HongKongTour/.
# run the desktop app
cd /Applications/HongKongTour && python3 hongkong_tour.py # port 8570
# rebuild the four desktop packs + Android
cd /tmp && env -u PYTHONPATH /bin/bash \
/Applications/HongKongTour/build/build_linux_windows.sh
Use native /bin/bash: a Homebrew bash is x86_64 and drags the build into Rosetta, where xcrun and the SDK disagree. Unset PYTHONPATH when calling the scripts — a stale PIL in a venv breaks the image step otherwise.
# verify every pack
env -u PYTHONPATH python3 /Applications/HongKongTour/build/verify_all_packs.py
# rebuild the static browser build
env -u PYTHONPATH python3 /Applications/HongKongTour/build/build_web.py