Full analysis · software & apps

Hong Kong Tour

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.

143 spots 27 neighbourhoods 7 delivery targets 56/56 pack checks written 2026-10-19

01Summary

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.

02By the numbers

Measured from the shipped build (data.json and dist/), not from memory.

ContentValue
Spots143
Regions3
Neighbourhoods / areas27  (8 / 7 / 12)
Spots per region38 / 32 / 73
Categories in use15
Spots with a photo143 / 143
Spots with rating, tips, location143 / 143
Spots with a map link102 / 143
Ratings3.5 – 4.9  (15 values)
Descriptions41,678 chars  (avg 291)
Tipsavg 162 chars
BuildSizeFiles
Data (hk_data.py)136,820 B · 1,849 lines1
Interface (index.html)22,331 B · 657 lines1
Launcher5,911 B · 147 lines1
Build tooling885 lines across 5 scripts5
Source photos300 MB268
Browser build28 MB170  (166 photos)
data.json111,088 B1
InstallerExact bytesCard label
macOS .dmg309,378,949295 MB
Windows .exe306,419,009292 MB
Linux .deb306,294,892292 MB
Portable .zip306,139,800292 MB
Android .apk2,425,1482.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.

03The data model

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"}

Why the Chinese is inside name

The 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 spread

CategorySpotsCategorySpots
scenic23museum7
landmark19temple6
neighbourhood19arts_culture4
hiking16food2
beach14nightlife1
market12entertainment1
park10no category1
shopping8

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.

04One core, seven targets

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.

TargetShellHow it is produced
macOS .app / .dmgpywebview → WKWebViewApp bundle + launcher script, ad-hoc signed, packed with hdiutil
Windows .exepywebview → Edge WebView27-Zip SFX self-extractor around the portable tree
Linux .debpywebview → WebKitGTKar archive written byte-by-byte by make_deb.py
Portable .zippywebviewSame tree, plus .command / .bat helpers
Android .apkWebView wrapperGradle + Android SDK, assets = the same HTML + data
iOS / iPadOSSwiftUI + WKWebViewXcode project generated by gen_ios_project.py
BrowsernoneStatic 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.

05The five packaging traps

Each of these produced an artifact that passed a naive check. Each is a macOS-toolchain or hosting-limit problem, not an app problem.

1 · The .deb was 96 bytes

symptom: file exists, plausible name, no content

Cause. 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.

2 · The Windows .exe was a Mac binary

symptom: 306 MB .exe that Windows refuses to run

Cause. 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/.

3 · The macOS app would not open

symptom: double-click does nothing, no dialog

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.

4 · Cloudflare Pages silently deletes files over 25 MiB

symptom: deploy says success, installer URLs 404

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.

5 · GitHub refuses the files, the site refuses the size

symptom: nowhere to put 1.2 GB of installers

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.

06The browser build

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:

Two bugs the static build introduced, both caught by tests:
  • The shim built its index at parse time, before loadData() had populated the data — so search returned nothing. Now the index is built lazily on first search.
  • Search results called 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.

07What is actually proven

Claims here are backed by the check that produced them, not by "it deployed".

ClaimEvidence
All six packs are validverify_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 photodata.json → 143/143, and the APK's assets → 143/143
The static demo worksPlaywright: 15/15 — search, jump-to-result, category chips, no CJK duplication, no 404s, no JS errors
Installers download correctlyFull 309,378,949 B pull from the live URL → SHA-256 identical to the local build
Downloads resumeWindows .exe cut at 125,829,120 B then resumed → final file SHA-256 identical; all five serve Accept-Ranges: bytes
The card worksPlaywright on the live site: 16/16 — all actions render, clicking a download fires the same-origin URL
The mirror is standalonePlaywright 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.

08Known gaps

One spot has no category. 林村許願樹 Lam Tsuen Wishing Trees (Tai Po, New Territories) is missing its 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.
The content skews to the New Territories. 73 of 143 spots (51%) are in the NT versus 38 on Hong Kong Island and 32 in Kowloon, largely because country parks and outlying islands carry many entries. Not wrong, but not balanced either.
iOS is source, not a download. The Xcode project is generated and valid, but producing an .ipa needs full Xcode plus a signing identity. This machine has Command Line Tools only, so no iOS build is claimed.
macOS first launch needs a deliberate open. The app is ad-hoc signed, so Gatekeeper blocks a plain double-click: right-click → Open, or System Settings → Privacy & Security → Open Anyway. Removing that step needs a paid Apple Developer ID and notarisation.
The desktop builds are heavy for what they do. ~300 MB each, almost entirely interpreter and webview runtime. A rewrite in a compiled stack, or shipping the browser build as a native shell (Tauri/Wails), would plausibly land under 20 MB.
Photographs are sourced from Wikimedia Commons. Suitable for a personal project with attribution, but each file's licence should be audited before any commercial use. This is the one part of the project I would not call cleared.

09Where it lives

All five installers are on the portfolio card's download buttons, served same-origin and resumable. Source lives at /Applications/HongKongTour/.

10Reproducing it

# 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