xppb 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xppb-1.0.0/PKG-INFO +153 -0
- xppb-1.0.0/README.md +144 -0
- xppb-1.0.0/pyproject.toml +16 -0
- xppb-1.0.0/src/xppb/__init__.py +3 -0
- xppb-1.0.0/src/xppb/launcher_stub.c +30 -0
- xppb-1.0.0/src/xppb/xppb.py +638 -0
xppb-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xppb
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A blazing-fast cross-platform Python bundling pipeline tool.
|
|
5
|
+
Author-email: nulsie <donotemailme@mail.com>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: modulegraph>=0.17.4
|
|
9
|
+
|
|
10
|
+
# xppb (cross-platform python bundler)
|
|
11
|
+
|
|
12
|
+
xppb is a blazing-fast truly cross-platform binary bundler designed to compile, prune, and package Python applications into standalone, production-ready executables for Windows, macOS, and Linux from a single host machine.
|
|
13
|
+
|
|
14
|
+
Unlike traditional bundlers that are severely limited by their host operating system, xppb natively supports **true cross-compilation**. By temporarily spoofing the Python interpreter's global state and relying on standard archives, a developer on Linux can seamlessly build a signed Windows `.exe` and a fully notarized macOS `.app` bundle simultaneously in a single execution in a matter of seconds(takes a lot configuration and 20-30 min in Nuitka for reference).
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
While tools like PyInstaller, Nuitka, and cx_Freeze are industry standards, xppb bypasses their historical limitations through a modern, aggressive architecture:
|
|
19
|
+
|
|
20
|
+
* **True Single-Host Cross-Compilation**: PyInstaller and Nuitka require you to build Windows binaries on a Windows host. xppb utilizes a `mock_target_environment` context manager to trick the host Python environment and `modulegraph` into evaluating code paths as if they were running natively on the target operating system.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
* **Radical "Default-Deny" Bloat Pruning**: Traditional bundlers pull in massive directories based on exclusion lists. xppb uses a strict surgical whitelist, deleting every file in `site-packages` and the standard library that is not explicitly resolved by the dependency graph. It safely preserves only the structural `__init__.py` files, compiled extensions, and active metadata blocks (like `.dist-info`).
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
* **Host-Agnostic Apple Code-Signing**: You no longer need an Xcode-equipped Mac to notarize macOS software. xppb automatically fetches Indygreg’s cross-platform `rcodesign` tool to cryptographically sign bundles and submit them directly to the Apple Notary API from Linux or Windows environments.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
* **Lightning Fast Dependency Resolution**: xppb searches the host for Astral's `uv` binary. If found, it routes dependency installation through `uv`, utilizing its `--platform` and `--abi` flags to download platform-specific wheels in a fraction of the time standard `pip` would take.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
* **Zero-Flash Windows Launchers**: Instead of relying solely on slow script wrappers, xppb scans the host for native C compilers (`gcc`, `cl`, etc.) and dynamically templates and compiles a C-based executable (`launcher_stub.c`) to launch the application silently without console flashes.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Core Features Under the Hood
|
|
39
|
+
|
|
40
|
+
### 1. The Environment Spoofer (`mock_target_environment`)
|
|
41
|
+
|
|
42
|
+
To trace platform-specific dependencies (like `winreg` on Windows or `termios` on Linux) without crashing the host machine, xppb temporarily intercepts and rewrites Python's core system identifiers.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
# Modifies global state thread-safely before ModuleGraph execution
|
|
46
|
+
if "windows" in target_os:
|
|
47
|
+
sys.platform = "win32"
|
|
48
|
+
os.name = "nt"
|
|
49
|
+
sys.builtin_module_names = tuple(set(orig_builtins) | {"winreg", "msvcrt", "_winapi", "nt"})
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 2. C-Extension Binary Rescuer
|
|
54
|
+
|
|
55
|
+
Compiled modules (`.so` / `.pyd`) often hide dynamic imports in their C code that standard AST tracers miss. xppb sweeps compiled extensions, reading them as binary data and matching valid ASCII strings against available system modules to rescue hidden dependencies.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
with open(file_path, "rb") as f:
|
|
59
|
+
data = f.read()
|
|
60
|
+
for s in data.split(b'\0'):
|
|
61
|
+
# Look for strings that match the active environment file map
|
|
62
|
+
if 2 < len(s) < 50 and s.replace(b'.', b'').replace(b'_', b'').isalnum():
|
|
63
|
+
# ...rescues missing modules...
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 3. Persistent Runtime Caching
|
|
68
|
+
|
|
69
|
+
To save bandwidth across frequent builds, xppb downloads massive standard standard-library `.tar.gz` runtimes into a persistent user-level directory (`~/.core_bundler_cache/runtimes`). Subsequent cross-compilations pull locally from the cache instantly.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## On Using It
|
|
74
|
+
|
|
75
|
+
## Through uv:
|
|
76
|
+
```
|
|
77
|
+
uv pip install xppb
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Through pip:
|
|
81
|
+
just remove the uv from the above command.
|
|
82
|
+
|
|
83
|
+
## Git Clone:
|
|
84
|
+
```
|
|
85
|
+
git clone https://codeberg.org/nulsie/xppb.git
|
|
86
|
+
cd xppb
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Prerequisites
|
|
90
|
+
|
|
91
|
+
* Python 3.11+
|
|
92
|
+
* `pip install modulegraph`
|
|
93
|
+
|
|
94
|
+
* *(Optional but Recommended)*: Install `uv` for drastically accelerated wheel downloads.
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
### Configuration (`projconf.toml`)
|
|
99
|
+
|
|
100
|
+
xppb is entirely configuration-driven. Create a file named `projconf.toml` in your project root. Here is a complete reference of the required layout:
|
|
101
|
+
|
|
102
|
+
```toml
|
|
103
|
+
[project]
|
|
104
|
+
name = "MyAwesomeApp"
|
|
105
|
+
version = "1.0.0"
|
|
106
|
+
python_version = "3.11"
|
|
107
|
+
entry_point = "main.py"
|
|
108
|
+
source_files = ["main.py", "assets/"]
|
|
109
|
+
dependencies = ["requests", "rich", "numpy"]
|
|
110
|
+
hidden_imports = ["pkg_resources.extern"]
|
|
111
|
+
collect_all = []
|
|
112
|
+
preserve_extensions = [".png", ".ico"]
|
|
113
|
+
launch_command = "{ENTRY_POINT}"
|
|
114
|
+
|
|
115
|
+
[runtimes]
|
|
116
|
+
# URLs pointing to standalone python standard libraries (.tar.gz)
|
|
117
|
+
windows-x64 = "https://example.com/python-3.11-win64.tar.gz"
|
|
118
|
+
macos-arm64 = "https://example.com/python-3.11-macos-arm64.tar.gz"
|
|
119
|
+
linux-x64 = "https://example.com/python-3.11-linux64.tar.gz"
|
|
120
|
+
|
|
121
|
+
[windows]
|
|
122
|
+
hide_console = true
|
|
123
|
+
pfx_certificate = "certs/win_cert.pfx"
|
|
124
|
+
pfx_password = "SuperSecretPassword123"
|
|
125
|
+
hidden_imports = ["winreg"]
|
|
126
|
+
|
|
127
|
+
[macos]
|
|
128
|
+
p12_certificate = "certs/mac_cert.p12"
|
|
129
|
+
p12_password = "SuperSecretPassword123"
|
|
130
|
+
api_key_path = "certs/AuthKey_ABCD123.p8"
|
|
131
|
+
api_issuer_id = "xxxxxx-xxxx-xxxx-xxxx-xxxxxxxx"
|
|
132
|
+
api_key_id = "ABCD123"
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Usage
|
|
137
|
+
|
|
138
|
+
Once your `projconf.toml` is configured, simply run `xppb` in your working directory and execute it:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
xppb
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
xppb utilizes a `ProcessPoolExecutor` to spin up a concurrent build thread for every OS target defined in your `[runtimes]` configuration block.
|
|
146
|
+
|
|
147
|
+
* Build artifacts are processed temporarily in a `build/` directory.
|
|
148
|
+
|
|
149
|
+
* Final, compressed distribution assets (ready to be uploaded to GitHub Releases or S3) are deposited into `dist/`.
|
|
150
|
+
|
|
151
|
+
-----
|
|
152
|
+
|
|
153
|
+
**Author:** nulsie **License:** MIT License
|
xppb-1.0.0/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# xppb (cross-platform python bundler)
|
|
2
|
+
|
|
3
|
+
xppb is a blazing-fast truly cross-platform binary bundler designed to compile, prune, and package Python applications into standalone, production-ready executables for Windows, macOS, and Linux from a single host machine.
|
|
4
|
+
|
|
5
|
+
Unlike traditional bundlers that are severely limited by their host operating system, xppb natively supports **true cross-compilation**. By temporarily spoofing the Python interpreter's global state and relying on standard archives, a developer on Linux can seamlessly build a signed Windows `.exe` and a fully notarized macOS `.app` bundle simultaneously in a single execution in a matter of seconds(takes a lot configuration and 20-30 min in Nuitka for reference).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
While tools like PyInstaller, Nuitka, and cx_Freeze are industry standards, xppb bypasses their historical limitations through a modern, aggressive architecture:
|
|
10
|
+
|
|
11
|
+
* **True Single-Host Cross-Compilation**: PyInstaller and Nuitka require you to build Windows binaries on a Windows host. xppb utilizes a `mock_target_environment` context manager to trick the host Python environment and `modulegraph` into evaluating code paths as if they were running natively on the target operating system.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
* **Radical "Default-Deny" Bloat Pruning**: Traditional bundlers pull in massive directories based on exclusion lists. xppb uses a strict surgical whitelist, deleting every file in `site-packages` and the standard library that is not explicitly resolved by the dependency graph. It safely preserves only the structural `__init__.py` files, compiled extensions, and active metadata blocks (like `.dist-info`).
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
* **Host-Agnostic Apple Code-Signing**: You no longer need an Xcode-equipped Mac to notarize macOS software. xppb automatically fetches Indygreg’s cross-platform `rcodesign` tool to cryptographically sign bundles and submit them directly to the Apple Notary API from Linux or Windows environments.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
* **Lightning Fast Dependency Resolution**: xppb searches the host for Astral's `uv` binary. If found, it routes dependency installation through `uv`, utilizing its `--platform` and `--abi` flags to download platform-specific wheels in a fraction of the time standard `pip` would take.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
* **Zero-Flash Windows Launchers**: Instead of relying solely on slow script wrappers, xppb scans the host for native C compilers (`gcc`, `cl`, etc.) and dynamically templates and compiles a C-based executable (`launcher_stub.c`) to launch the application silently without console flashes.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Core Features Under the Hood
|
|
30
|
+
|
|
31
|
+
### 1. The Environment Spoofer (`mock_target_environment`)
|
|
32
|
+
|
|
33
|
+
To trace platform-specific dependencies (like `winreg` on Windows or `termios` on Linux) without crashing the host machine, xppb temporarily intercepts and rewrites Python's core system identifiers.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
# Modifies global state thread-safely before ModuleGraph execution
|
|
37
|
+
if "windows" in target_os:
|
|
38
|
+
sys.platform = "win32"
|
|
39
|
+
os.name = "nt"
|
|
40
|
+
sys.builtin_module_names = tuple(set(orig_builtins) | {"winreg", "msvcrt", "_winapi", "nt"})
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 2. C-Extension Binary Rescuer
|
|
45
|
+
|
|
46
|
+
Compiled modules (`.so` / `.pyd`) often hide dynamic imports in their C code that standard AST tracers miss. xppb sweeps compiled extensions, reading them as binary data and matching valid ASCII strings against available system modules to rescue hidden dependencies.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
with open(file_path, "rb") as f:
|
|
50
|
+
data = f.read()
|
|
51
|
+
for s in data.split(b'\0'):
|
|
52
|
+
# Look for strings that match the active environment file map
|
|
53
|
+
if 2 < len(s) < 50 and s.replace(b'.', b'').replace(b'_', b'').isalnum():
|
|
54
|
+
# ...rescues missing modules...
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 3. Persistent Runtime Caching
|
|
59
|
+
|
|
60
|
+
To save bandwidth across frequent builds, xppb downloads massive standard standard-library `.tar.gz` runtimes into a persistent user-level directory (`~/.core_bundler_cache/runtimes`). Subsequent cross-compilations pull locally from the cache instantly.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## On Using It
|
|
65
|
+
|
|
66
|
+
## Through uv:
|
|
67
|
+
```
|
|
68
|
+
uv pip install xppb
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Through pip:
|
|
72
|
+
just remove the uv from the above command.
|
|
73
|
+
|
|
74
|
+
## Git Clone:
|
|
75
|
+
```
|
|
76
|
+
git clone https://codeberg.org/nulsie/xppb.git
|
|
77
|
+
cd xppb
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Prerequisites
|
|
81
|
+
|
|
82
|
+
* Python 3.11+
|
|
83
|
+
* `pip install modulegraph`
|
|
84
|
+
|
|
85
|
+
* *(Optional but Recommended)*: Install `uv` for drastically accelerated wheel downloads.
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
### Configuration (`projconf.toml`)
|
|
90
|
+
|
|
91
|
+
xppb is entirely configuration-driven. Create a file named `projconf.toml` in your project root. Here is a complete reference of the required layout:
|
|
92
|
+
|
|
93
|
+
```toml
|
|
94
|
+
[project]
|
|
95
|
+
name = "MyAwesomeApp"
|
|
96
|
+
version = "1.0.0"
|
|
97
|
+
python_version = "3.11"
|
|
98
|
+
entry_point = "main.py"
|
|
99
|
+
source_files = ["main.py", "assets/"]
|
|
100
|
+
dependencies = ["requests", "rich", "numpy"]
|
|
101
|
+
hidden_imports = ["pkg_resources.extern"]
|
|
102
|
+
collect_all = []
|
|
103
|
+
preserve_extensions = [".png", ".ico"]
|
|
104
|
+
launch_command = "{ENTRY_POINT}"
|
|
105
|
+
|
|
106
|
+
[runtimes]
|
|
107
|
+
# URLs pointing to standalone python standard libraries (.tar.gz)
|
|
108
|
+
windows-x64 = "https://example.com/python-3.11-win64.tar.gz"
|
|
109
|
+
macos-arm64 = "https://example.com/python-3.11-macos-arm64.tar.gz"
|
|
110
|
+
linux-x64 = "https://example.com/python-3.11-linux64.tar.gz"
|
|
111
|
+
|
|
112
|
+
[windows]
|
|
113
|
+
hide_console = true
|
|
114
|
+
pfx_certificate = "certs/win_cert.pfx"
|
|
115
|
+
pfx_password = "SuperSecretPassword123"
|
|
116
|
+
hidden_imports = ["winreg"]
|
|
117
|
+
|
|
118
|
+
[macos]
|
|
119
|
+
p12_certificate = "certs/mac_cert.p12"
|
|
120
|
+
p12_password = "SuperSecretPassword123"
|
|
121
|
+
api_key_path = "certs/AuthKey_ABCD123.p8"
|
|
122
|
+
api_issuer_id = "xxxxxx-xxxx-xxxx-xxxx-xxxxxxxx"
|
|
123
|
+
api_key_id = "ABCD123"
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Usage
|
|
128
|
+
|
|
129
|
+
Once your `projconf.toml` is configured, simply run `xppb` in your working directory and execute it:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
xppb
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
xppb utilizes a `ProcessPoolExecutor` to spin up a concurrent build thread for every OS target defined in your `[runtimes]` configuration block.
|
|
137
|
+
|
|
138
|
+
* Build artifacts are processed temporarily in a `build/` directory.
|
|
139
|
+
|
|
140
|
+
* Final, compressed distribution assets (ready to be uploaded to GitHub Releases or S3) are deposited into `dist/`.
|
|
141
|
+
|
|
142
|
+
-----
|
|
143
|
+
|
|
144
|
+
**Author:** nulsie **License:** MIT License
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["flit_core >=3.2,<4"]
|
|
3
|
+
build-backend = "flit_core.buildapi"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xppb"
|
|
7
|
+
authors = [{name = "nulsie", email = "donotemailme@mail.com"}]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dynamic = ["version", "description"]
|
|
11
|
+
dependencies = [
|
|
12
|
+
"modulegraph>=0.17.4",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
xppb = "xppb.xppb:main"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#include <windows.h>
|
|
2
|
+
#include <stdio.h>
|
|
3
|
+
#include <string.h>
|
|
4
|
+
|
|
5
|
+
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) {
|
|
6
|
+
char exePath[MAX_PATH];
|
|
7
|
+
GetModuleFileNameA(NULL, exePath, MAX_PATH);
|
|
8
|
+
|
|
9
|
+
char* lastSlash = strrchr(exePath, '\\');
|
|
10
|
+
if (lastSlash != NULL) {
|
|
11
|
+
*lastSlash = '\0';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
STARTUPINFO si;
|
|
15
|
+
PROCESS_INFORMATION pi;
|
|
16
|
+
ZeroMemory(&si, sizeof(si));
|
|
17
|
+
si.cb = sizeof(si);
|
|
18
|
+
ZeroMemory(&pi, sizeof(pi));
|
|
19
|
+
|
|
20
|
+
char cmd[32768];
|
|
21
|
+
// We use tokens here that Python will replace before compilation
|
|
22
|
+
snprintf(cmd, sizeof(cmd), "\"%s\\python\\__PYTHON_EXE__\" \"%s\\__ENTRY_POINT__\" %s", exePath, exePath, lpCmdLine);
|
|
23
|
+
|
|
24
|
+
if (CreateProcessA(NULL, cmd, NULL, NULL, FALSE, __CREATE_WINDOW_FLAG__, NULL, NULL, &si, &pi)) {
|
|
25
|
+
WaitForSingleObject(pi.hProcess, INFINITE);
|
|
26
|
+
CloseHandle(pi.hProcess);
|
|
27
|
+
CloseHandle(pi.hThread);
|
|
28
|
+
}
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import shutil
|
|
5
|
+
import urllib.request
|
|
6
|
+
import tarfile
|
|
7
|
+
import zipfile
|
|
8
|
+
import subprocess
|
|
9
|
+
import platform
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import concurrent.futures
|
|
12
|
+
import tempfile
|
|
13
|
+
import contextlib
|
|
14
|
+
|
|
15
|
+
@contextlib.contextmanager
|
|
16
|
+
def mock_target_environment(platform_name):
|
|
17
|
+
"""
|
|
18
|
+
Temporarily tricks the host Python environment and ModuleGraph into
|
|
19
|
+
evaluating logic as if it were running natively on the target OS.
|
|
20
|
+
"""
|
|
21
|
+
orig_platform = sys.platform
|
|
22
|
+
orig_os = os.name
|
|
23
|
+
orig_builtins = sys.builtin_module_names
|
|
24
|
+
target_os = platform_name.lower()
|
|
25
|
+
if 'windows' in target_os:
|
|
26
|
+
sys.platform = 'win32'
|
|
27
|
+
os.name = 'nt'
|
|
28
|
+
sys.builtin_module_names = tuple(set(orig_builtins) | {'winreg', 'msvcrt', '_winapi', 'nt'})
|
|
29
|
+
elif 'macos' in target_os:
|
|
30
|
+
sys.platform = 'darwin'
|
|
31
|
+
os.name = 'posix'
|
|
32
|
+
sys.builtin_module_names = tuple(set(orig_builtins) | {'posix'})
|
|
33
|
+
elif 'linux' in target_os:
|
|
34
|
+
sys.platform = 'linux'
|
|
35
|
+
os.name = 'posix'
|
|
36
|
+
sys.builtin_module_names = tuple(set(orig_builtins) | {'posix'})
|
|
37
|
+
try:
|
|
38
|
+
yield
|
|
39
|
+
finally:
|
|
40
|
+
sys.platform = orig_platform
|
|
41
|
+
os.name = orig_os
|
|
42
|
+
sys.builtin_module_names = orig_builtins
|
|
43
|
+
try:
|
|
44
|
+
from modulegraph.modulegraph import ModuleGraph
|
|
45
|
+
except ImportError:
|
|
46
|
+
print("[-] Error: 'modulegraph' is required for advanced dependency analysis.")
|
|
47
|
+
print(' Install it via: pip install modulegraph')
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
def generate_runtime_whitelist(entry_point_path, site_packages_path, stdlib_path, platform_name, hidden_imports=None, collect_all=None):
|
|
51
|
+
"""
|
|
52
|
+
Uses ModuleGraph to trace required files, catching C-extensions and dynamic imports,
|
|
53
|
+
with an optimized, precise package file-level whitelisting system.
|
|
54
|
+
"""
|
|
55
|
+
if hidden_imports is None:
|
|
56
|
+
hidden_imports = []
|
|
57
|
+
if collect_all is None:
|
|
58
|
+
collect_all = []
|
|
59
|
+
print(f' -> Tracing application dependencies via ModuleGraph (Targeting: {platform_name})...')
|
|
60
|
+
target_paths = [str(site_packages_path), str(stdlib_path)]
|
|
61
|
+
graph = ModuleGraph(path=target_paths)
|
|
62
|
+
scripts_to_trace = [str(entry_point_path)]
|
|
63
|
+
if hidden_imports:
|
|
64
|
+
print(f' [*] Resolving {len(hidden_imports)} hidden imports...')
|
|
65
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as temp_script:
|
|
66
|
+
for mod in hidden_imports:
|
|
67
|
+
temp_script.write(f'import {mod}\n')
|
|
68
|
+
temp_script_path = temp_script.name
|
|
69
|
+
scripts_to_trace.append(temp_script_path)
|
|
70
|
+
try:
|
|
71
|
+
with mock_target_environment(platform_name):
|
|
72
|
+
graph = ModuleGraph(path=target_paths)
|
|
73
|
+
for script in scripts_to_trace:
|
|
74
|
+
graph.run_script(script)
|
|
75
|
+
finally:
|
|
76
|
+
if hidden_imports and os.path.exists(temp_script_path):
|
|
77
|
+
os.remove(temp_script_path)
|
|
78
|
+
whitelist = set()
|
|
79
|
+
missing_modules = set()
|
|
80
|
+
base_paths = [Path(site_packages_path).resolve(), Path(stdlib_path).resolve()]
|
|
81
|
+
|
|
82
|
+
def smart_whitelist_add(p):
|
|
83
|
+
"""Surgical path addition: breaks the parent directory shield."""
|
|
84
|
+
p = Path(p).resolve()
|
|
85
|
+
if p.is_dir():
|
|
86
|
+
for sub_p in p.rglob('*'):
|
|
87
|
+
if sub_p.is_file():
|
|
88
|
+
smart_whitelist_add(sub_p)
|
|
89
|
+
return
|
|
90
|
+
whitelist.add(p)
|
|
91
|
+
parent = p.parent
|
|
92
|
+
while parent:
|
|
93
|
+
if not any((base in parent.parents or parent == base for base in base_paths)):
|
|
94
|
+
break
|
|
95
|
+
for init_name in ['__init__.py', '__init__.pyc']:
|
|
96
|
+
init_file = parent / init_name
|
|
97
|
+
if init_file.exists():
|
|
98
|
+
whitelist.add(init_file.resolve())
|
|
99
|
+
parent = parent.parent
|
|
100
|
+
for node in graph.nodes():
|
|
101
|
+
if hasattr(node, 'filename') and node.filename:
|
|
102
|
+
smart_whitelist_add(node.filename)
|
|
103
|
+
if hasattr(node, 'packagepath') and node.packagepath:
|
|
104
|
+
for pkg_dir in node.packagepath:
|
|
105
|
+
if pkg_dir:
|
|
106
|
+
smart_whitelist_add(pkg_dir)
|
|
107
|
+
if type(node).__name__ == 'MissingModule':
|
|
108
|
+
missing_modules.add(node.identifier)
|
|
109
|
+
essential_modules = ['site.py', 'os.py', 'stat.py', 'genericpath.py', 'encodings', 'codecs.py', 'io.py', 'abc.py', '_collections_abc.py', 'sitecustomize.py', 'importlib']
|
|
110
|
+
target_os = platform_name.lower()
|
|
111
|
+
if 'windows' in target_os:
|
|
112
|
+
essential_modules.extend(['ntpath.py', 'nt.py', '_winapi', 'msvcrt', 'winreg', 'socket.py', '_socket'])
|
|
113
|
+
else:
|
|
114
|
+
essential_modules.extend(['posixpath.py', 'posix.py', 'fcntl', 'termios', 'socket.py', '_socket'])
|
|
115
|
+
for essential in essential_modules:
|
|
116
|
+
for p in Path(stdlib_path).rglob(essential):
|
|
117
|
+
smart_whitelist_add(p)
|
|
118
|
+
print(' [*] Scanning compiled C-extensions for implicit dynamic imports...')
|
|
119
|
+
target_files = {}
|
|
120
|
+
for search_dir in base_paths:
|
|
121
|
+
for p in search_dir.rglob('*.py'):
|
|
122
|
+
target_files[p.stem] = p
|
|
123
|
+
rescued_binary_deps = 0
|
|
124
|
+
for file_path in list(whitelist):
|
|
125
|
+
if file_path.suffix.lower() in {'.so', '.pyd'}:
|
|
126
|
+
try:
|
|
127
|
+
with open(file_path, 'rb') as f:
|
|
128
|
+
data = f.read()
|
|
129
|
+
for s in data.split(b'\x00'):
|
|
130
|
+
if 2 < len(s) < 50 and s.replace(b'.', b'').replace(b'_', b'').isalnum():
|
|
131
|
+
try:
|
|
132
|
+
mod_string = s.decode('ascii')
|
|
133
|
+
base_name = mod_string.split('.')[-1]
|
|
134
|
+
if base_name in target_files and target_files[base_name] not in whitelist:
|
|
135
|
+
smart_whitelist_add(target_files[base_name])
|
|
136
|
+
rescued_binary_deps += 1
|
|
137
|
+
except UnicodeDecodeError:
|
|
138
|
+
pass
|
|
139
|
+
except OSError:
|
|
140
|
+
pass
|
|
141
|
+
if rescued_binary_deps > 0:
|
|
142
|
+
print(f' [+] Binary scanner uncovered {rescued_binary_deps} hidden C-extension dependencies.')
|
|
143
|
+
if missing_modules:
|
|
144
|
+
print(f' [*] Cross-compilation check: Attempting to rescue {len(missing_modules)} unresolved modules from target runtime...')
|
|
145
|
+
target_files = {}
|
|
146
|
+
for search_dir in base_paths:
|
|
147
|
+
for p in search_dir.rglob('*'):
|
|
148
|
+
if p.is_file():
|
|
149
|
+
target_files[p.stem] = p
|
|
150
|
+
rescued_count = 0
|
|
151
|
+
for bad_mod in missing_modules:
|
|
152
|
+
base_name = bad_mod.split('.')[-1]
|
|
153
|
+
if base_name in target_files:
|
|
154
|
+
smart_whitelist_add(target_files[base_name])
|
|
155
|
+
rescued_count += 1
|
|
156
|
+
if rescued_count > 0:
|
|
157
|
+
print(f' [+] Rescued {rescued_count} platform-specific modules from the cutting room floor.')
|
|
158
|
+
if collect_all:
|
|
159
|
+
print(f" [*] Applying 'collect_all' override for {len(collect_all)} dynamic packages...")
|
|
160
|
+
for pkg_name in collect_all:
|
|
161
|
+
pkg_path = Path(site_packages_path) / pkg_name
|
|
162
|
+
if pkg_path.exists() and pkg_path.is_dir():
|
|
163
|
+
smart_whitelist_add(pkg_path)
|
|
164
|
+
for metadata_dir in Path(site_packages_path).glob(f'{pkg_name}-*.*-info'):
|
|
165
|
+
smart_whitelist_add(metadata_dir)
|
|
166
|
+
else:
|
|
167
|
+
print(f" [!] Warning: collect_all package '{pkg_name}' not found. Did you forget to list it in DEPENDENCIES?")
|
|
168
|
+
return whitelist
|
|
169
|
+
|
|
170
|
+
def prune_runtime_bloat_strict(runtime_path, whitelist, site_packages_path, stdlib_path, extra_extensions=None):
|
|
171
|
+
"""
|
|
172
|
+
Deletes EVERY file within the code-distribution scopes that is NOT in the whitelist,
|
|
173
|
+
while safely preserving critical non-Python data assets, compiled extensions,
|
|
174
|
+
and surgical metadata blocks for active packages.
|
|
175
|
+
"""
|
|
176
|
+
print(' -> Executing radical whitelisting (Strict Surgical Prune with Asset & Metadata Rescue)...')
|
|
177
|
+
runtime_path = Path(runtime_path)
|
|
178
|
+
bytes_saved = 0
|
|
179
|
+
preserve_extensions = {'.so', '.pyd', '.dylib', '.dll', '.pem', '.crt', '.json', '.yaml', '.yml', '.txt', '.csv', '.tcl', '.tk'}
|
|
180
|
+
if extra_extensions:
|
|
181
|
+
preserve_extensions.update((ext.lower() if ext.startswith('.') else f'.{ext.lower()}' for ext in extra_extensions))
|
|
182
|
+
target_zones = [Path(site_packages_path).resolve(), Path(stdlib_path).resolve()]
|
|
183
|
+
active_package_dirs = {p.parent.resolve() for p in whitelist}
|
|
184
|
+
active_module_names = set()
|
|
185
|
+
for p in whitelist:
|
|
186
|
+
if p.parent in target_zones:
|
|
187
|
+
active_module_names.add(p.stem.lower().replace('_', ''))
|
|
188
|
+
else:
|
|
189
|
+
for parent in p.parents:
|
|
190
|
+
if parent.parent in target_zones:
|
|
191
|
+
active_module_names.add(parent.name.lower().replace('_', ''))
|
|
192
|
+
break
|
|
193
|
+
for zone in target_zones:
|
|
194
|
+
if not zone.exists():
|
|
195
|
+
continue
|
|
196
|
+
for file_path in zone.rglob('*'):
|
|
197
|
+
if not file_path.is_file() and (not file_path.is_symlink()):
|
|
198
|
+
continue
|
|
199
|
+
resolved_file = file_path.resolve()
|
|
200
|
+
if resolved_file in whitelist:
|
|
201
|
+
continue
|
|
202
|
+
if resolved_file.suffix.lower() in preserve_extensions:
|
|
203
|
+
is_active_asset = False
|
|
204
|
+
parent = resolved_file.parent
|
|
205
|
+
while parent and parent != zone and (parent != parent.parent):
|
|
206
|
+
if parent in active_package_dirs:
|
|
207
|
+
is_active_asset = True
|
|
208
|
+
break
|
|
209
|
+
parent = parent.parent
|
|
210
|
+
if is_active_asset:
|
|
211
|
+
whitelist.add(resolved_file)
|
|
212
|
+
continue
|
|
213
|
+
is_metadata_file = False
|
|
214
|
+
for parent in resolved_file.parents:
|
|
215
|
+
if parent == zone:
|
|
216
|
+
break
|
|
217
|
+
if parent.name.endswith('.dist-info') or parent.name.endswith('.egg-info'):
|
|
218
|
+
critical_meta_files = {'METADATA', 'entry_points.txt', 'top_level.txt', 'PKG-INFO'}
|
|
219
|
+
if resolved_file.name in critical_meta_files:
|
|
220
|
+
meta_base_name = parent.name.split('-')[0].lower().replace('_', '')
|
|
221
|
+
if meta_base_name in active_module_names:
|
|
222
|
+
is_metadata_file = True
|
|
223
|
+
whitelist.add(resolved_file)
|
|
224
|
+
break
|
|
225
|
+
if is_metadata_file:
|
|
226
|
+
continue
|
|
227
|
+
try:
|
|
228
|
+
bytes_saved += file_path.stat().st_size
|
|
229
|
+
file_path.unlink()
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
232
|
+
for dir_path in sorted(runtime_path.rglob('*'), key=lambda x: len(x.parts), reverse=True):
|
|
233
|
+
if dir_path.is_dir() and (not any(dir_path.iterdir())):
|
|
234
|
+
try:
|
|
235
|
+
dir_path.rmdir()
|
|
236
|
+
except OSError:
|
|
237
|
+
pass
|
|
238
|
+
print(f' [+] Pruning complete. Reclaimed ~{bytes_saved / (1024 * 1024):.1f} MB.')
|
|
239
|
+
|
|
240
|
+
def load_configuration(config_filename='projconf.toml'):
|
|
241
|
+
"""Reads and maps the external TOML configuration layer."""
|
|
242
|
+
config_path = Path(config_filename)
|
|
243
|
+
if not config_path.exists():
|
|
244
|
+
print(f"[-] Error: Configuration file '{config_filename}' not found in the current working directory.")
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
print(f'Loading environment manifest: {config_filename}')
|
|
247
|
+
with open(config_path, 'rb') as f:
|
|
248
|
+
try:
|
|
249
|
+
toml_data = tomllib.load(f)
|
|
250
|
+
return {'PROJECT_NAME': toml_data['project']['name'], 'VERSION': toml_data['project']['version'], 'PYTHON_VERSION': toml_data['project'].get('python_version', '3.11'), 'SOURCE_FILES': toml_data['project'].get('source_files', []), 'ENTRY_POINT': toml_data['project']['entry_point'], 'DEPENDENCIES': toml_data['project'].get('dependencies', []), 'HIDDEN_IMPORTS': toml_data['project'].get('hidden_imports', []), 'COLLECT_ALL': toml_data['project'].get('collect_all', []), 'LAUNCH_COMMAND': toml_data['project']['launch_command'], 'PRESERVE_EXTENSIONS': toml_data['project'].get('preserve_extensions', []), 'RUNTIMES': toml_data['runtimes'], 'WINDOWS_CONFIG': toml_data.get('windows', {}), 'MACOS_CONFIG': toml_data.get('macos', {}), 'LINUX_CONFIG': toml_data.get('linux', {})}
|
|
251
|
+
except KeyError as e:
|
|
252
|
+
print(f'[-] Configuration Error: Missing required TOML key definition {e}')
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
print(f'[-] Syntax Error parsing TOML file: {e}')
|
|
256
|
+
sys.exit(1)
|
|
257
|
+
|
|
258
|
+
def find_site_packages(base_path):
|
|
259
|
+
"""Recursively crawls the runtime structure to locate the vendor folder."""
|
|
260
|
+
for root, dirs, _ in os.walk(base_path):
|
|
261
|
+
if os.path.basename(root) == 'site-packages':
|
|
262
|
+
return root
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
def download_runtime(url, dest_path):
|
|
266
|
+
"""
|
|
267
|
+
Downloads the python runtime, utilizing a global cache to save bandwidth
|
|
268
|
+
and significantly speed up subsequent builds.
|
|
269
|
+
"""
|
|
270
|
+
CACHE_DIR = Path.home() / '.core_bundler_cache' / 'runtimes'
|
|
271
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
filename = url.split('/')[-1]
|
|
273
|
+
cached_file = CACHE_DIR / filename
|
|
274
|
+
if cached_file.exists():
|
|
275
|
+
print(f' [+] Cache hit! Using local runtime archive: {filename}')
|
|
276
|
+
shutil.copy2(cached_file, dest_path)
|
|
277
|
+
return
|
|
278
|
+
print(f' [~] Downloading runtime to cache from {url}...')
|
|
279
|
+
try:
|
|
280
|
+
urllib.request.urlretrieve(url, cached_file)
|
|
281
|
+
print(f' [+] Download complete. Cached permanently at {CACHE_DIR}')
|
|
282
|
+
shutil.copy2(cached_file, dest_path)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
print(f' [!] Failed to download runtime: {e}')
|
|
285
|
+
if cached_file.exists():
|
|
286
|
+
cached_file.unlink()
|
|
287
|
+
sys.exit(1)
|
|
288
|
+
|
|
289
|
+
def extract_runtime(archive_path, extract_to):
|
|
290
|
+
"""Extracts the .tar.gz bundle into the workspace securely, preventing path traversal attacks."""
|
|
291
|
+
print(f' -> Unpacking engine files securely...')
|
|
292
|
+
extract_to_path = Path(extract_to).resolve()
|
|
293
|
+
with tarfile.open(archive_path, 'r:gz') as tar:
|
|
294
|
+
if hasattr(tarfile, 'data_filter'):
|
|
295
|
+
try:
|
|
296
|
+
tar.extractall(path=extract_to_path, filter='data')
|
|
297
|
+
return
|
|
298
|
+
except (TypeError, ValueError):
|
|
299
|
+
pass
|
|
300
|
+
safe_members = []
|
|
301
|
+
for member in tar.getmembers():
|
|
302
|
+
target_path = Path(os.path.abspath(os.path.join(extract_to_path, member.name)))
|
|
303
|
+
if extract_to_path not in target_path.parents and target_path != extract_to_path:
|
|
304
|
+
print(f"[-] Malicious Archive Detected: Refusing to extract '{member.name}' (outside boundary).")
|
|
305
|
+
sys.exit(1)
|
|
306
|
+
safe_members.append(member)
|
|
307
|
+
tar.extractall(path=extract_to_path, members=safe_members)
|
|
308
|
+
|
|
309
|
+
def download_and_extract_deps(deps, target_site_packages, temp_dir, platform_key, python_version):
|
|
310
|
+
"""Resolves and properly installs specified packages using uv (if available) or pip."""
|
|
311
|
+
if not deps:
|
|
312
|
+
return
|
|
313
|
+
platform_map = {'windows-x64': {'platform': 'win_amd64', 'abi': f"cp{python_version.replace('.', '')}"}, 'windows': {'platform': 'win_amd64', 'abi': f"cp{python_version.replace('.', '')}"}, 'linux-x64': {'platform': 'manylinux2014_x86_64', 'abi': f"cp{python_version.replace('.', '')}"}, 'linux': {'platform': 'manylinux2014_x86_64', 'abi': f"cp{python_version.replace('.', '')}"}, 'macos-x64': {'platform': 'macosx_10_9_x86_64', 'abi': f"cp{python_version.replace('.', '')}"}, 'macos-arm64': {'platform': 'macosx_11_0_arm64', 'abi': f"cp{python_version.replace('.', '')}"}}
|
|
314
|
+
lookup_key = platform_key.lower()
|
|
315
|
+
plat_info = platform_map.get(lookup_key)
|
|
316
|
+
print(f" -> Syncing dependencies for {platform_key} (Targeting Python {python_version}): {', '.join(deps)}...")
|
|
317
|
+
uv_bin = shutil.which('uv')
|
|
318
|
+
if uv_bin:
|
|
319
|
+
print(" [*] Acceleration Engaged: Routing dependency resolution through 'uv'...")
|
|
320
|
+
cmd = [uv_bin, 'pip', 'install', '--target', str(target_site_packages), '--only-binary=:all:']
|
|
321
|
+
else:
|
|
322
|
+
cmd = [sys.executable, '-m', 'pip', 'install', '--target', str(target_site_packages), '--only-binary=:all:', '--no-warn-script-location']
|
|
323
|
+
if plat_info:
|
|
324
|
+
cmd.extend(['--platform', plat_info['platform'], '--abi', plat_info['abi'], '--python-version', python_version, '--implementation', 'cp'])
|
|
325
|
+
cmd.extend(deps)
|
|
326
|
+
try:
|
|
327
|
+
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
328
|
+
except subprocess.CalledProcessError:
|
|
329
|
+
installer = 'uv' if uv_bin else 'pip'
|
|
330
|
+
print(f" [!] Error: {installer} failed to resolve or install target variants for: {', '.join(deps)}")
|
|
331
|
+
raise RuntimeError(f'Dependency resolution failed via {installer}.')
|
|
332
|
+
|
|
333
|
+
def generate_info_plist(dest_path, project_name, version):
|
|
334
|
+
"""Generates and writes a compliant macOS Info.plist file."""
|
|
335
|
+
plist_content = f'<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>CFBundlePackageType</key>\n <string>APPL</string>\n <key>CFBundleInfoDictionaryVersion</key>\n <string>6.0</string>\n <key>CFBundleName</key>\n <string>{project_name}</string>\n <key>CFBundleExecutable</key>\n <string>{project_name}</string>\n <key>CFBundleIdentifier</key>\n <string>com.standalone.{project_name.lower()}</string>\n <key>CFBundleShortVersionString</key>\n <string>{version}</string>\n <key>CFBundleVersion</key>\n <string>{version}</string>\n <key>LSMinimumSystemVersion</key>\n <string>10.13</string>\n</dict>\n</plist>\n'
|
|
336
|
+
dest_path.write_text(plist_content)
|
|
337
|
+
|
|
338
|
+
def find_windows_compiler():
|
|
339
|
+
"""Detects native toolchains or cross-compilers on the host platform."""
|
|
340
|
+
for compiler in ['gcc', 'x86_64-w64-mingw32-gcc', 'cl']:
|
|
341
|
+
if shutil.which(compiler):
|
|
342
|
+
return compiler
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
def find_signtool():
|
|
346
|
+
"""Recursively searches traditional Windows SDK locations for signtool.exe."""
|
|
347
|
+
if shutil.which('signtool'):
|
|
348
|
+
return 'signtool'
|
|
349
|
+
possible_roots = [Path('C:/Program Files (x86)/Windows Kits/10/bin'), Path('C:/Program Files/Windows Kits/10/bin'), Path('C:/Program Files (x86)/Microsoft SDKs/ClickOnce/SignTool')]
|
|
350
|
+
for root in possible_roots:
|
|
351
|
+
if not root.exists():
|
|
352
|
+
continue
|
|
353
|
+
if root.name == 'SignTool':
|
|
354
|
+
exe = root / 'signtool.exe'
|
|
355
|
+
if exe.exists():
|
|
356
|
+
return str(exe)
|
|
357
|
+
else:
|
|
358
|
+
for sub in root.iterdir():
|
|
359
|
+
if sub.is_dir():
|
|
360
|
+
for arch in ['x64', 'x86']:
|
|
361
|
+
exe = sub / arch / 'signtool.exe'
|
|
362
|
+
if exe.exists():
|
|
363
|
+
return str(exe)
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
def sign_windows_executable(target_file, win_config):
|
|
367
|
+
"""Integrates code signing parameters if a certificate context is provided."""
|
|
368
|
+
cert_path = win_config.get('pfx_certificate')
|
|
369
|
+
cert_password = win_config.get('pfx_password')
|
|
370
|
+
if not cert_path or not cert_password:
|
|
371
|
+
return
|
|
372
|
+
signtool_bin = find_signtool()
|
|
373
|
+
if not signtool_bin:
|
|
374
|
+
print(' [!] Warning: signtool.exe missing from common paths. Skipping signature routine.')
|
|
375
|
+
return
|
|
376
|
+
print(f' -> Code signing target binary: {target_file.name}...')
|
|
377
|
+
cmd = [signtool_bin, 'sign', '/f', str(cert_path), '/p', cert_password, '/tr', 'http://timestamp.digicert.com', '/td', 'sha256', '/fd', 'sha256', str(target_file)]
|
|
378
|
+
try:
|
|
379
|
+
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
380
|
+
print('[+] Code signature successfully embedded.')
|
|
381
|
+
except subprocess.CalledProcessError:
|
|
382
|
+
print(' [!] Error: SignTool invocation failed. Verify certificate properties.')
|
|
383
|
+
|
|
384
|
+
def generate_windows_launcher(platform_dir, project_name, entry_point, win_config):
|
|
385
|
+
"""Builds a zero-flash compiled binary using decoupled source files and guaranteed cleanup paths."""
|
|
386
|
+
hide_console = win_config.get('hide_console', False)
|
|
387
|
+
compiler = find_windows_compiler()
|
|
388
|
+
launcher_exe = platform_dir / f'{project_name}.exe'
|
|
389
|
+
entry_point_normalized = entry_point.replace('\\', '/')
|
|
390
|
+
entry_point_fixed = entry_point_normalized.replace('/', '\\\\')
|
|
391
|
+
python_exe = 'pythonw.exe' if hide_console else 'python.exe'
|
|
392
|
+
create_window_flag = 'CREATE_NO_WINDOW' if hide_console else '0'
|
|
393
|
+
if compiler:
|
|
394
|
+
print(f' -> Found build engine ({compiler}). Compiling native Win32 executable launcher...')
|
|
395
|
+
c_source_path = platform_dir / '_launcher.c'
|
|
396
|
+
stub_path = Path(__file__).parent / "launcher_stub.c"
|
|
397
|
+
if not stub_path.exists():
|
|
398
|
+
print(" [!] Error: 'launcher_stub.c' template missing. Reverting to fallback wrappers...")
|
|
399
|
+
compiler = None
|
|
400
|
+
else:
|
|
401
|
+
try:
|
|
402
|
+
raw_c_code = stub_path.read_text()
|
|
403
|
+
c_code = raw_c_code.replace('__PYTHON_EXE__', python_exe)
|
|
404
|
+
c_code = c_code.replace('__ENTRY_POINT__', entry_point_fixed)
|
|
405
|
+
c_code = c_code.replace('__CREATE_WINDOW_FLAG__', create_window_flag)
|
|
406
|
+
c_source_path.write_text(c_code)
|
|
407
|
+
if compiler == 'cl':
|
|
408
|
+
subprocess.run(['cl.exe', '/O2', f'/Fe:{launcher_exe}', str(c_source_path), '/link', '/SUBSYSTEM:WINDOWS'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
409
|
+
else:
|
|
410
|
+
build_cmd = [compiler, '-O2', str(c_source_path), '-o', str(launcher_exe)]
|
|
411
|
+
if hide_console:
|
|
412
|
+
build_cmd.append('-mwindows')
|
|
413
|
+
subprocess.run(build_cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
414
|
+
return launcher_exe
|
|
415
|
+
except Exception:
|
|
416
|
+
print(' [!] Compilation pipeline exception. Reverting to structural fallback scripts...')
|
|
417
|
+
if launcher_exe.exists():
|
|
418
|
+
launcher_exe.unlink()
|
|
419
|
+
finally:
|
|
420
|
+
if c_source_path.exists():
|
|
421
|
+
c_source_path.unlink()
|
|
422
|
+
for junk in [platform_dir / '_launcher.obj', platform_dir / f'{project_name}.obj']:
|
|
423
|
+
if junk.exists():
|
|
424
|
+
junk.unlink()
|
|
425
|
+
print(' -> Constructing basic script wrapper (No native compiler available)...')
|
|
426
|
+
launcher_bat = platform_dir / f'{project_name}.bat'
|
|
427
|
+
bat_content = f'@echo off\nsetlocal\n"%~dp0python\\{python_exe}" "%~dp0{entry_point}" %*\nendlocal\n'
|
|
428
|
+
launcher_bat.write_text(bat_content)
|
|
429
|
+
if hide_console:
|
|
430
|
+
vbs_launcher = platform_dir / f'{project_name}.vbs'
|
|
431
|
+
vbs_content = f'Set WshShell = CreateObject("WScript.Shell")\nWshShell.Run chr(34) & "{project_name}.bat" & chr(34), 0\nSet WshShell = Nothing\n'
|
|
432
|
+
vbs_launcher.write_text(vbs_content)
|
|
433
|
+
return vbs_launcher
|
|
434
|
+
return launcher_bat
|
|
435
|
+
|
|
436
|
+
def ensure_rcodesign():
|
|
437
|
+
"""Locates or automatically downloads the cross-platform apple-codesign tool binary."""
|
|
438
|
+
system_path = shutil.which('rcodesign')
|
|
439
|
+
if system_path:
|
|
440
|
+
return system_path
|
|
441
|
+
tools_dir = Path('build/tools')
|
|
442
|
+
tools_dir.mkdir(parents=True, exist_ok=True)
|
|
443
|
+
host_sys = sys.platform
|
|
444
|
+
version = '0.29.0'
|
|
445
|
+
base_url = f'https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F{version}/'
|
|
446
|
+
if host_sys == 'win32':
|
|
447
|
+
asset_name = f'apple-codesign-{version}-x86_64-pc-windows-msvc.zip'
|
|
448
|
+
binary_name = 'rcodesign.exe'
|
|
449
|
+
elif host_sys == 'darwin':
|
|
450
|
+
asset_name = f'apple-codesign-{version}-macos-universal.tar.gz'
|
|
451
|
+
binary_name = 'rcodesign'
|
|
452
|
+
else:
|
|
453
|
+
asset_name = f'apple-codesign-{version}-x86_64-unknown-linux-musl.tar.gz'
|
|
454
|
+
binary_name = 'rcodesign'
|
|
455
|
+
local_binary = tools_dir / binary_name
|
|
456
|
+
if local_binary.exists():
|
|
457
|
+
return str(local_binary)
|
|
458
|
+
print(f' -> Apple tools missing. Auto-fetching cross-platform rcodesign v{version} for host pipeline...')
|
|
459
|
+
archive_dest = tools_dir / asset_name
|
|
460
|
+
try:
|
|
461
|
+
req = urllib.request.Request(base_url + asset_name, headers={'User-Agent': 'Mozilla/5.0'})
|
|
462
|
+
with urllib.request.urlopen(req) as response, open(archive_dest, 'wb') as out_file:
|
|
463
|
+
shutil.copyfileobj(response, out_file)
|
|
464
|
+
if asset_name.endswith('.zip'):
|
|
465
|
+
with zipfile.ZipFile(archive_dest, 'r') as zip_ref:
|
|
466
|
+
zip_ref.extractall(tools_dir)
|
|
467
|
+
else:
|
|
468
|
+
with tarfile.open(archive_dest, 'r:gz') as tar:
|
|
469
|
+
tar.extractall(path=tools_dir)
|
|
470
|
+
for root, _, files in os.walk(tools_dir):
|
|
471
|
+
if binary_name in files:
|
|
472
|
+
target = Path(root) / binary_name
|
|
473
|
+
if target != local_binary:
|
|
474
|
+
shutil.move(str(target), str(local_binary))
|
|
475
|
+
break
|
|
476
|
+
if local_binary.exists():
|
|
477
|
+
os.chmod(local_binary, 493)
|
|
478
|
+
if archive_dest.exists():
|
|
479
|
+
archive_dest.unlink()
|
|
480
|
+
return str(local_binary)
|
|
481
|
+
except Exception as e:
|
|
482
|
+
print(f' [!] Failed to download cross-platform signing dependency: {e}')
|
|
483
|
+
sys.exit(1)
|
|
484
|
+
|
|
485
|
+
def sign_macos_bundle_cross_platform(app_path, mac_config, rcodesign_bin):
|
|
486
|
+
"""Signs a macOS .app bundle structure flawlessly from any supported OS platform context."""
|
|
487
|
+
p12_cert = mac_config.get('p12_certificate')
|
|
488
|
+
p12_pass = mac_config.get('p12_password')
|
|
489
|
+
if not p12_cert:
|
|
490
|
+
print(' [-] Skipping macOS signing engine: No .p12 certificate file defined.')
|
|
491
|
+
return
|
|
492
|
+
print(f' -> Cryptographically signing macOS application framework: {app_path.name}...')
|
|
493
|
+
cmd = [rcodesign_bin, 'sign', '--p12-file', str(p12_cert), '--p12-password', str(p12_pass), '--code-signature-flags', 'runtime', str(app_path)]
|
|
494
|
+
try:
|
|
495
|
+
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
|
496
|
+
print(' [+] Apple Developer signature successfully validated and compiled.')
|
|
497
|
+
except subprocess.CalledProcessError as e:
|
|
498
|
+
print(f' [!] Error: rcodesign execution error:\n{e.stderr}')
|
|
499
|
+
sys.exit(1)
|
|
500
|
+
|
|
501
|
+
def notarize_macos_bundle_cross_platform(app_path, mac_config, rcodesign_bin):
|
|
502
|
+
"""Submits the payload to Apple Notary API and staples tickets directly inside non-macOS setups."""
|
|
503
|
+
api_key = mac_config.get('api_key_path')
|
|
504
|
+
api_issuer = mac_config.get('api_issuer_id')
|
|
505
|
+
api_key_id = mac_config.get('api_key_id')
|
|
506
|
+
if not all([api_key, api_issuer, api_key_id]):
|
|
507
|
+
print(' [-] Skipping Apple Notarization: ASC API Keys are incomplete.')
|
|
508
|
+
return
|
|
509
|
+
print(f' -> Submitting bundle to Apple Notary API servers (Cross-Platform Connection)...')
|
|
510
|
+
cmd = [rcodesign_bin, 'notary-submit', '--api-key-path', str(api_key), '--api-issuer', str(api_issuer), '--api-key', str(api_key_id), '--staple', str(app_path)]
|
|
511
|
+
try:
|
|
512
|
+
subprocess.run(cmd, check=True)
|
|
513
|
+
print(' [+] Notarization ticket acquired and stapled into app bundle.')
|
|
514
|
+
except subprocess.CalledProcessError:
|
|
515
|
+
print(' [!] Error: Apple Notary Service API transaction failed.')
|
|
516
|
+
sys.exit(1)
|
|
517
|
+
|
|
518
|
+
def bundle_platform(platform_name, url, build_dir, dist_dir, base_temp_dir, CONFIG):
|
|
519
|
+
"""Encapsulates the build process for a single target platform to allow concurrent execution."""
|
|
520
|
+
print(f'\n[Target Platform: {platform_name}] Process started...')
|
|
521
|
+
platform_dir = build_dir / platform_name
|
|
522
|
+
platform_dir.mkdir(parents=True, exist_ok=True)
|
|
523
|
+
archive_path = build_dir / f'engine_{platform_name}.tar.gz'
|
|
524
|
+
temp_dir = base_temp_dir / platform_name
|
|
525
|
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
526
|
+
is_macos = 'macos' in platform_name.lower()
|
|
527
|
+
if is_macos:
|
|
528
|
+
app_bundle = platform_dir / f"{CONFIG['PROJECT_NAME']}.app"
|
|
529
|
+
contents_dir = app_bundle / 'Contents'
|
|
530
|
+
macos_dir = contents_dir / 'MacOS'
|
|
531
|
+
resources_dir = contents_dir / 'Resources'
|
|
532
|
+
macos_dir.mkdir(parents=True, exist_ok=True)
|
|
533
|
+
resources_dir.mkdir(parents=True, exist_ok=True)
|
|
534
|
+
runtime_extract_target = resources_dir
|
|
535
|
+
source_dest_parent = resources_dir
|
|
536
|
+
else:
|
|
537
|
+
runtime_extract_target = platform_dir
|
|
538
|
+
source_dest_parent = platform_dir
|
|
539
|
+
download_runtime(url, archive_path)
|
|
540
|
+
extract_runtime(archive_path, runtime_extract_target)
|
|
541
|
+
site_packages = find_site_packages(runtime_extract_target)
|
|
542
|
+
if not site_packages:
|
|
543
|
+
print(f' [!] Error: Failed to pinpoint target environment layout for {platform_name}!')
|
|
544
|
+
return
|
|
545
|
+
download_and_extract_deps(CONFIG['DEPENDENCIES'], site_packages, temp_dir, platform_name, python_version=CONFIG['PYTHON_VERSION'])
|
|
546
|
+
stdlib_path = Path(site_packages).parent
|
|
547
|
+
active_hidden = list(CONFIG.get('HIDDEN_IMPORTS', []))
|
|
548
|
+
active_collect = list(CONFIG.get('COLLECT_ALL', []))
|
|
549
|
+
plat_lower = platform_name.lower()
|
|
550
|
+
if 'windows' in plat_lower:
|
|
551
|
+
active_hidden.extend(CONFIG['WINDOWS_CONFIG'].get('hidden_imports', []))
|
|
552
|
+
active_collect.extend(CONFIG['WINDOWS_CONFIG'].get('collect_all', []))
|
|
553
|
+
elif 'macos' in plat_lower:
|
|
554
|
+
active_hidden.extend(CONFIG['MACOS_CONFIG'].get('hidden_imports', []))
|
|
555
|
+
active_collect.extend(CONFIG['MACOS_CONFIG'].get('collect_all', []))
|
|
556
|
+
elif 'linux' in plat_lower:
|
|
557
|
+
active_hidden.extend(CONFIG['LINUX_CONFIG'].get('hidden_imports', []))
|
|
558
|
+
active_collect.extend(CONFIG['LINUX_CONFIG'].get('collect_all', []))
|
|
559
|
+
whitelist = generate_runtime_whitelist(entry_point_path=Path(CONFIG['ENTRY_POINT']), site_packages_path=Path(site_packages), stdlib_path=stdlib_path, platform_name=platform_name, hidden_imports=active_hidden, collect_all=active_collect)
|
|
560
|
+
prune_runtime_bloat_strict(runtime_path=runtime_extract_target, whitelist=whitelist, site_packages_path=site_packages, stdlib_path=stdlib_path, extra_extensions=CONFIG.get('PRESERVE_EXTENSIONS', []))
|
|
561
|
+
print(f' -> [{platform_name}] Packaging custom source layers...')
|
|
562
|
+
for src in CONFIG['SOURCE_FILES']:
|
|
563
|
+
src_path = Path(src)
|
|
564
|
+
if not src_path.exists():
|
|
565
|
+
print(f" [!] Missing source path ignored: '{src}'")
|
|
566
|
+
continue
|
|
567
|
+
dest_path = source_dest_parent / src_path.name
|
|
568
|
+
if src_path.is_dir():
|
|
569
|
+
shutil.copytree(src_path, dest_path)
|
|
570
|
+
else:
|
|
571
|
+
shutil.copy2(src_path, dest_path)
|
|
572
|
+
print(f' -> [{platform_name}] Constructing isolated binary wrappers...')
|
|
573
|
+
if 'windows' in platform_name:
|
|
574
|
+
launcher_path = generate_windows_launcher(platform_dir, CONFIG['PROJECT_NAME'], CONFIG['ENTRY_POINT'], CONFIG['WINDOWS_CONFIG'])
|
|
575
|
+
sign_windows_executable(launcher_path, CONFIG['WINDOWS_CONFIG'])
|
|
576
|
+
elif is_macos:
|
|
577
|
+
launcher_path = macos_dir / CONFIG['PROJECT_NAME']
|
|
578
|
+
launch_cmd = CONFIG['LAUNCH_COMMAND'].format(ENTRY_POINT=f"$SCRIPT_DIR/../Resources/{CONFIG['ENTRY_POINT']}")
|
|
579
|
+
sh_content = f'#!/usr/bin/env bash\nSCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"\n"$SCRIPT_DIR/../Resources/python/bin/python3" {launch_cmd} "$@"\n'
|
|
580
|
+
launcher_path.write_text(sh_content)
|
|
581
|
+
generate_info_plist(contents_dir / 'Info.plist', CONFIG['PROJECT_NAME'], CONFIG['VERSION'])
|
|
582
|
+
os.chmod(launcher_path, 493)
|
|
583
|
+
python_bin = resources_dir / 'python' / 'bin' / 'python3'
|
|
584
|
+
if python_bin.exists():
|
|
585
|
+
os.chmod(python_bin, 493)
|
|
586
|
+
rcodesign_bin = ensure_rcodesign()
|
|
587
|
+
sign_macos_bundle_cross_platform(app_bundle, CONFIG['MACOS_CONFIG'], rcodesign_bin)
|
|
588
|
+
notarize_macos_bundle_cross_platform(app_bundle, CONFIG['MACOS_CONFIG'], rcodesign_bin)
|
|
589
|
+
else:
|
|
590
|
+
launcher_path = platform_dir / CONFIG['PROJECT_NAME']
|
|
591
|
+
launch_cmd = CONFIG['LAUNCH_COMMAND'].format(ENTRY_POINT=f"$SCRIPT_DIR/{CONFIG['ENTRY_POINT']}")
|
|
592
|
+
sh_content = f'#!/usr/bin/env bash\nSCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"\n"$SCRIPT_DIR/python/bin/python3" {launch_cmd} "$@"\n'
|
|
593
|
+
launcher_path.write_text(sh_content)
|
|
594
|
+
os.chmod(launcher_path, 493)
|
|
595
|
+
python_bin = platform_dir / 'python' / 'bin' / 'python3'
|
|
596
|
+
if python_bin.exists():
|
|
597
|
+
os.chmod(python_bin, 493)
|
|
598
|
+
print(f' -> [{platform_name}] Sealing compressed release asset...')
|
|
599
|
+
dist_base_name = dist_dir / f"{CONFIG['PROJECT_NAME']}-{CONFIG['VERSION']}-{platform_name}"
|
|
600
|
+
if 'windows' in platform_name:
|
|
601
|
+
shutil.make_archive(str(dist_base_name), 'zip', root_dir=platform_dir)
|
|
602
|
+
print(f' [+] Archive ready: {dist_base_name}.zip')
|
|
603
|
+
else:
|
|
604
|
+
shutil.make_archive(str(dist_base_name), 'gztar', root_dir=platform_dir)
|
|
605
|
+
print(f' [+] Archive ready: {dist_base_name}.tar.gz')
|
|
606
|
+
if archive_path.exists():
|
|
607
|
+
archive_path.unlink()
|
|
608
|
+
|
|
609
|
+
def main():
|
|
610
|
+
CONFIG = load_configuration()
|
|
611
|
+
build_dir = Path('build')
|
|
612
|
+
dist_dir = Path('dist')
|
|
613
|
+
base_temp_dir = Path('build/temp_deps')
|
|
614
|
+
if build_dir.exists():
|
|
615
|
+
shutil.rmtree(build_dir)
|
|
616
|
+
if dist_dir.exists():
|
|
617
|
+
shutil.rmtree(dist_dir)
|
|
618
|
+
build_dir.mkdir(parents=True, exist_ok=True)
|
|
619
|
+
dist_dir.mkdir(parents=True, exist_ok=True)
|
|
620
|
+
base_temp_dir.mkdir(parents=True, exist_ok=True)
|
|
621
|
+
print(f'\n=== xppb v1.0.0')
|
|
622
|
+
print(f"\n=== Bundling Sequence Initiated: {CONFIG['PROJECT_NAME']} v{CONFIG['VERSION']} ===")
|
|
623
|
+
with concurrent.futures.ProcessPoolExecutor() as executor:
|
|
624
|
+
futures = []
|
|
625
|
+
for platform_name, url in CONFIG['RUNTIMES'].items():
|
|
626
|
+
futures.append(executor.submit(bundle_platform, platform_name, url, build_dir, dist_dir, base_temp_dir, CONFIG))
|
|
627
|
+
for future in concurrent.futures.as_completed(futures):
|
|
628
|
+
try:
|
|
629
|
+
future.result()
|
|
630
|
+
except Exception as e:
|
|
631
|
+
print(f'\n[!] A thread encountered a fatal error during compilation: {e}')
|
|
632
|
+
if base_temp_dir.exists():
|
|
633
|
+
shutil.rmtree(base_temp_dir)
|
|
634
|
+
print('\n========================================================================')
|
|
635
|
+
print(f'Success! Standalone bundles have been compiled inside: {dist_dir.resolve()}')
|
|
636
|
+
print('========================================================================')
|
|
637
|
+
if __name__ == '__main__':
|
|
638
|
+
main()
|