py2apk 0.3.0__py3-none-any.whl
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.
- py2apk-0.3.0.dist-info/METADATA +203 -0
- py2apk-0.3.0.dist-info/RECORD +7 -0
- py2apk-0.3.0.dist-info/WHEEL +5 -0
- py2apk-0.3.0.dist-info/entry_points.txt +2 -0
- py2apk-0.3.0.dist-info/licenses/LICENSE +12 -0
- py2apk-0.3.0.dist-info/top_level.txt +1 -0
- py2apk.py +698 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py2apk
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Turn a Python script into an Android APK with a GUI - no Docker, no compiling on your machine
|
|
5
|
+
Author: TwoPlasmaStudios
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://github.com/TwoPlasmaStudios/py2apk
|
|
8
|
+
Project-URL: Issues, https://github.com/TwoPlasmaStudios/py2apk/issues
|
|
9
|
+
Keywords: android,apk,python,packaging,flask,webview,gui
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: X11 Applications
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Provides-Extra: icon
|
|
22
|
+
Requires-Dist: pillow; extra == "icon"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# py2apk
|
|
26
|
+
|
|
27
|
+
[Türkçe](README.tr.md)
|
|
28
|
+
|
|
29
|
+
Turn a Python script into an Android APK, from a simple desktop GUI.
|
|
30
|
+
No Docker, no WSL, no compiler on your machine, and it runs on Windows, macOS and Linux.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install py2apk
|
|
34
|
+
py2apk
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Pick your `.py` file, choose an app type, press **Build APK**. That's it.
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
|
|
41
|
+
py2apk is split in two parts so that you never have to compile anything:
|
|
42
|
+
|
|
43
|
+
1. **The factory** (`factory.py` + a GitHub Actions workflow) runs once on GitHub.
|
|
44
|
+
It cross-compiles CPython for Android (arm64-v8a and x86_64), builds the Android
|
|
45
|
+
wheels for a set of popular libraries (Flask, NumPy, pandas, Pillow, ...), and
|
|
46
|
+
publishes everything as a GitHub Release.
|
|
47
|
+
2. **The tool** (`py2apk.py`, the GUI you run) downloads those ready-made pieces,
|
|
48
|
+
scans your imports, bundles your code and the libraries it needs, writes the
|
|
49
|
+
Android manifest, and packages and signs the APK.
|
|
50
|
+
|
|
51
|
+
The only things downloaded on your machine are a JDK 17 and the Android build-tools
|
|
52
|
+
(if you don't already have them), kept in `~/.py2apk`.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
From PyPI (once published):
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
pip install py2apk
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Straight from GitHub (works today):
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
pip install git+https://github.com/TwoPlasmaStudios/pyapk.git
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Requirements: Python 3.10+ with tkinter. The Windows and macOS installers from
|
|
69
|
+
python.org include tkinter. On Debian/Ubuntu: `sudo apt install python3-tk`.
|
|
70
|
+
Optional: `pip install py2apk[icon]` (Pillow) to use JPG/WebP logos and get a sharp preview.
|
|
71
|
+
|
|
72
|
+
## Use it
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
py2apk # opens the GUI
|
|
76
|
+
py2apk path/to/app.py # opens the GUI with a file preselected
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
1. **Python file**: your entry script. Other `.py` files and packages next to it are
|
|
80
|
+
included automatically, so keep the project in its own folder.
|
|
81
|
+
2. **App**: name, package name (e.g. `com.me.myapp`), version and an optional logo.
|
|
82
|
+
3. **App type**: see below.
|
|
83
|
+
4. **Libraries**: detected from your imports. Each one shows whether a ready-made
|
|
84
|
+
Android build exists. Add extra packages in the *Extra packages* field.
|
|
85
|
+
5. Press **Build APK**. The first build downloads the JDK and Android tools (several
|
|
86
|
+
hundred MB); later builds are fast. The APK is written next to your script.
|
|
87
|
+
|
|
88
|
+
Install it with `adb install MyApp-1.0.apk` or copy it to the phone and open it
|
|
89
|
+
(allow installs from unknown sources). The APK is signed with a debug key, which is
|
|
90
|
+
fine for testing and sideloading but not for Google Play.
|
|
91
|
+
|
|
92
|
+
### App types
|
|
93
|
+
|
|
94
|
+
| Type | Status | What you get |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| **Console** | works | `print()` output is shown on screen. For scripts, bots, calculations. No `input()`. |
|
|
97
|
+
| **Web UI** | works | Run Flask/FastAPI/etc. in your script. The app opens it full screen in a WebView. |
|
|
98
|
+
| **Pygame / Games** | not yet | Needs an SDL2 runtime. |
|
|
99
|
+
| **Kivy** | not yet | Needs an SDL2 runtime. |
|
|
100
|
+
|
|
101
|
+
Tkinter does not exist on Android. Use the **Web UI** type for graphical apps.
|
|
102
|
+
|
|
103
|
+
#### Web UI example
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import os
|
|
107
|
+
from flask import Flask
|
|
108
|
+
|
|
109
|
+
app = Flask(__name__)
|
|
110
|
+
|
|
111
|
+
@app.get("/")
|
|
112
|
+
def index():
|
|
113
|
+
return "<h1>Hello from Android!</h1>"
|
|
114
|
+
|
|
115
|
+
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", 5000)), debug=False)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Keep `debug=False` (the reloader starts a second process, which Android won't allow) and
|
|
119
|
+
listen on `127.0.0.1`. The port is set in the GUI and passed to your code as `PORT`.
|
|
120
|
+
If the server fails to start, the app shows the Python traceback instead of a blank page.
|
|
121
|
+
|
|
122
|
+
## Libraries
|
|
123
|
+
|
|
124
|
+
The factory builds Android wheels for a fixed list of popular packages
|
|
125
|
+
(see `LIBS` in `factory.py`), plus all of their dependencies. Pure-Python packages
|
|
126
|
+
work as they are. Packages with C extensions only work if an Android build exists.
|
|
127
|
+
|
|
128
|
+
* Packages in the Release are used first.
|
|
129
|
+
* Anything else is tried from PyPI (pure-Python or Android wheels) with `pip`.
|
|
130
|
+
* If nothing is found you get a warning in the log and the package is skipped.
|
|
131
|
+
|
|
132
|
+
Which libraries are available changes as the factory improves: open the
|
|
133
|
+
`index.json` asset of the Release (or look at the status column in the GUI).
|
|
134
|
+
Some heavy packages (SciPy, scikit-learn, OpenCV, anything needing Rust or Fortran
|
|
135
|
+
toolchains) are the hardest to cross-compile and may be marked as failed.
|
|
136
|
+
|
|
137
|
+
## Run your own factory
|
|
138
|
+
|
|
139
|
+
Fork the repository, enable Actions, give the workflow write access
|
|
140
|
+
(*Settings -> Actions -> General -> Workflow permissions -> Read and write*), then run
|
|
141
|
+
**Actions -> factory -> Run workflow**. When it finishes, a release tagged `cp313` appears.
|
|
142
|
+
Point the tool at your fork:
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
# Windows (PowerShell)
|
|
146
|
+
$env:PY2APK_REPO = "you/your-fork"
|
|
147
|
+
# macOS / Linux
|
|
148
|
+
export PY2APK_REPO=you/your-fork
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Or use *Advanced -> GitHub repository* inside the GUI.
|
|
152
|
+
|
|
153
|
+
## Environment variables
|
|
154
|
+
|
|
155
|
+
| Variable | Meaning |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `PY2APK_REPO` | GitHub `owner/repo` that hosts the Release (default `TwoPlasmaStudios/pyapk`) |
|
|
158
|
+
| `PY2APK_HOME` | Cache folder (default `~/.py2apk`) |
|
|
159
|
+
| `PY2APK_PY` | Release tag, i.e. the Python version (default `cp313`) |
|
|
160
|
+
|
|
161
|
+
## Troubleshooting
|
|
162
|
+
|
|
163
|
+
* **"Library index could not be fetched"**: the repository must be public and the factory
|
|
164
|
+
must have finished at least once.
|
|
165
|
+
* **App crashes or shows a traceback**: run `adb logcat -s pyapk` while opening it.
|
|
166
|
+
* **Import error for a native library**: that package has no Android build yet. Check its
|
|
167
|
+
status in the GUI.
|
|
168
|
+
* **Build fails while downloading tools**: check your connection; downloads resume from the cache.
|
|
169
|
+
|
|
170
|
+
## Limitations
|
|
171
|
+
|
|
172
|
+
* Python 3.13 runtime, arm64-v8a and x86_64 only, minimum Android 7.0 (API 24).
|
|
173
|
+
* No tkinter, pygame or Kivy yet.
|
|
174
|
+
* Console apps have no interactive input.
|
|
175
|
+
* Debug-signed APKs only.
|
|
176
|
+
|
|
177
|
+
## Publishing to PyPI (maintainers)
|
|
178
|
+
|
|
179
|
+
Releases are published by `.github/workflows/publish.yml` with PyPI Trusted Publishing
|
|
180
|
+
(no API token needed). It only runs for tags that start with `v`, so the `cp313` release
|
|
181
|
+
created by the factory never triggers it.
|
|
182
|
+
|
|
183
|
+
One-time setup:
|
|
184
|
+
|
|
185
|
+
1. Check that the name is free: https://pypi.org/project/py2apk/ should return 404.
|
|
186
|
+
2. On pypi.org: *Your projects -> Publishing -> Add a new pending publisher* with
|
|
187
|
+
project `py2apk`, owner `TwoPlasmaStudios`, repository `pyapk`,
|
|
188
|
+
workflow `publish.yml`, environment `pypi`.
|
|
189
|
+
3. On GitHub: *Settings -> Environments -> New environment* named `pypi`.
|
|
190
|
+
|
|
191
|
+
Each release: bump `version` in `pyproject.toml`, then
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
git tag v0.3.0
|
|
195
|
+
git push origin v0.3.0
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Manual alternative: `pip install build twine`, `python -m build`, `twine upload dist/*`
|
|
199
|
+
(use an API token, username `__token__`).
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
MIT. Libraries in the Release keep their own licenses; CPython is under the PSF license.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
py2apk.py,sha256=Fh0aK5SHqfqnB2ZDUci93lndIg9CxzSBSxtz834ZZTk,36263
|
|
2
|
+
py2apk-0.3.0.dist-info/licenses/LICENSE,sha256=9TtYJ2FgdCTJPDmIX_SEGjQ4PKGmoclPeDvD6wLFzZk,1188
|
|
3
|
+
py2apk-0.3.0.dist-info/METADATA,sha256=rQ3OLnaQeDvs9YEtQ43oWDFsn0XTFuICyTrXQfe0JfE,7459
|
|
4
|
+
py2apk-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
py2apk-0.3.0.dist-info/entry_points.txt,sha256=4YAmgE4UsTNVQ2CijKHCoAnQBAW_g-Bwgl_BUWr_PbI,35
|
|
6
|
+
py2apk-0.3.0.dist-info/top_level.txt,sha256=qDhc3xcs25ljfpUnNwZ5JsUEmgHOg2Q6gsyFrtZvnMY,7
|
|
7
|
+
py2apk-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Copyright (c) 2026 Two Plasma Studios. All rights reserved.
|
|
2
|
+
|
|
3
|
+
CONFIDENTIALITY AND PROPRIETARY NOTICE
|
|
4
|
+
|
|
5
|
+
This software and all associated documentation files (the "Software") are the intellectual property of Two Plasma Studios.
|
|
6
|
+
|
|
7
|
+
Strictly Prohibition on Copying and Distribution:
|
|
8
|
+
1. No part of this Software may be copied, reproduced, modified, reverse engineered, decompiled, disassembled, or distributed in any form or by any means, electronic or mechanical, without the prior written permission of the copyright owner.
|
|
9
|
+
2. The Software is provided solely for personal running/execution via official package managers (e.g., pip).
|
|
10
|
+
3. Commercial use, reselling, or hosting the Software as a service (SaaS) is strictly prohibited.
|
|
11
|
+
|
|
12
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
py2apk
|
py2apk.py
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""py2apk - GUI ile Python -> APK (Windows / macOS / Linux).
|
|
3
|
+
|
|
4
|
+
Kullanıcı tarafında DERLEME YOK: CPython runtime'ı ve kütüphane wheel'leri
|
|
5
|
+
GitHub Releases'e önceden derlenip konmuştur (bkz. factory.py). Bu araç yalnızca
|
|
6
|
+
eksik araçları (JDK, Android build-tools) indirir, hazır parçaları çeker ve APK'yı paketler.
|
|
7
|
+
|
|
8
|
+
pip install py2apk -> py2apk
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast, json, os, platform, queue, re, shutil, struct, subprocess, sys, tarfile
|
|
13
|
+
import threading, traceback, urllib.parse, urllib.request, zipfile, zlib
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from xml.sax.saxutils import quoteattr
|
|
16
|
+
|
|
17
|
+
_old, _new = Path.home() / ".pyapk", Path.home() / ".py2apk" # eski önbelleği (JDK/SDK) yeniden indirme
|
|
18
|
+
HOME = Path(os.environ.get("PY2APK_HOME") or (_old if _old.exists() and not _new.exists() else _new))
|
|
19
|
+
REPO = os.environ.get("PY2APK_REPO") or os.environ.get("PYAPK_REPO") or "TwoPlasmaStudios/pyapk"
|
|
20
|
+
PY_TAG = os.environ.get("PY2APK_PY", "cp313") # release etiketi
|
|
21
|
+
ABIS = ("arm64-v8a", "x86_64")
|
|
22
|
+
BT_VER, PLATFORM, TARGET_SDK, MIN_SDK = "34.0.0", "android-34", 34, 24
|
|
23
|
+
CMDLINE = "https://dl.google.com/android/repository/commandlinetools-{os}-11076708_latest.zip"
|
|
24
|
+
SYS = platform.system()
|
|
25
|
+
WIN = SYS == "Windows"
|
|
26
|
+
ARCH = "aarch64" if platform.machine().lower() in ("arm64", "aarch64") else "x86_64"
|
|
27
|
+
EXE, BAT = (".exe", ".bat") if WIN else ("", "")
|
|
28
|
+
|
|
29
|
+
PIPMAP = {"PIL": "pillow", "cv2": "opencv-python-headless", "yaml": "pyyaml", "bs4": "beautifulsoup4",
|
|
30
|
+
"sklearn": "scikit-learn", "dateutil": "python-dateutil", "dotenv": "python-dotenv",
|
|
31
|
+
"OpenSSL": "pyopenssl", "Crypto": "pycryptodome", "serial": "pyserial", "jwt": "pyjwt",
|
|
32
|
+
"attr": "attrs", "skimage": "scikit-image", "git": "gitpython", "magic": "python-magic"}
|
|
33
|
+
UNSUPPORTED = {"tkinter": "Android'de Tk yok (Web arayüzü türünü kullanın)",
|
|
34
|
+
"pygame": "pygame için SDL altyapısı henüz yok", "kivy": "kivy için SDL altyapısı henüz yok"}
|
|
35
|
+
WEB_LIBS = {"flask", "fastapi", "bottle", "django", "quart", "starlette", "uvicorn", "waitress"}
|
|
36
|
+
MODES = [ # (anahtar, başlık, açıklama, destekleniyor)
|
|
37
|
+
("console", "Konsol", "print() çıktısı ekranda görünür. Betikler, botlar, hesaplamalar.", True),
|
|
38
|
+
("webview", "Web arayüzü", "Flask / FastAPI + HTML. Uygulama yerel sunucu açar, arayüz tam ekran WebView'da görünür.", True),
|
|
39
|
+
("pygame", "Pygame / Oyun", "SDL2 altyapısı gerekir. Henüz desteklenmiyor.", False),
|
|
40
|
+
("kivy", "Kivy", "SDL2 altyapısı gerekir. Henüz desteklenmiyor.", False),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------- günlük / hata
|
|
45
|
+
class Fail(Exception):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_log = print
|
|
50
|
+
def set_log(fn): global _log; _log = fn
|
|
51
|
+
def log(m): _log(m)
|
|
52
|
+
def die(m): raise Fail(m)
|
|
53
|
+
def norm(n): return re.sub(r"[-_.]+", "-", n).lower()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run(cmd, input=None, **kw):
|
|
57
|
+
cmd = [str(c) for c in cmd]
|
|
58
|
+
log("$ " + " ".join(cmd)[:200])
|
|
59
|
+
if WIN:
|
|
60
|
+
kw["creationflags"] = 0x08000000 # konsol penceresi açma
|
|
61
|
+
p = subprocess.Popen(cmd, stdin=subprocess.PIPE if input else None, stdout=subprocess.PIPE,
|
|
62
|
+
stderr=subprocess.STDOUT, text=True, errors="replace", **kw)
|
|
63
|
+
if input:
|
|
64
|
+
p.stdin.write(input); p.stdin.close()
|
|
65
|
+
for line in p.stdout:
|
|
66
|
+
log(line.rstrip())
|
|
67
|
+
if p.wait():
|
|
68
|
+
die(f"komut başarısız ({p.returncode}): {Path(cmd[0]).name}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def download(url, dest):
|
|
72
|
+
dest = Path(dest)
|
|
73
|
+
if dest.exists():
|
|
74
|
+
return dest
|
|
75
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
log(f"indiriliyor: {url}")
|
|
77
|
+
tmp = dest.with_name(dest.name + ".part")
|
|
78
|
+
try:
|
|
79
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "py2apk"}), timeout=60) as r, \
|
|
80
|
+
open(tmp, "wb") as f:
|
|
81
|
+
total, got, mark = int(r.headers.get("Content-Length") or 0), 0, 0
|
|
82
|
+
while chunk := r.read(1 << 20):
|
|
83
|
+
f.write(chunk); got += len(chunk)
|
|
84
|
+
if total and got - mark >= (16 << 20):
|
|
85
|
+
mark = got; log(f" {got >> 20}/{total >> 20} MB")
|
|
86
|
+
except Exception as e:
|
|
87
|
+
tmp.unlink(missing_ok=True); die(f"indirilemedi: {url} ({e})")
|
|
88
|
+
tmp.rename(dest)
|
|
89
|
+
return dest
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def extract(archive, dest):
|
|
93
|
+
dest = Path(dest); dest.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
if str(archive).endswith(".zip"):
|
|
95
|
+
with zipfile.ZipFile(archive) as z:
|
|
96
|
+
for i in z.infolist():
|
|
97
|
+
z.extract(i, dest)
|
|
98
|
+
mode = (i.external_attr >> 16) & 0o7777
|
|
99
|
+
if mode and not WIN:
|
|
100
|
+
os.chmod(dest / i.filename, mode)
|
|
101
|
+
else:
|
|
102
|
+
with tarfile.open(archive) as t:
|
|
103
|
+
t.extractall(dest, filter="tar") if hasattr(tarfile, "data_filter") else t.extractall(dest)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def jenv(jdk):
|
|
107
|
+
e = dict(os.environ)
|
|
108
|
+
e["JAVA_HOME"] = str(jdk)
|
|
109
|
+
e["PATH"] = str(Path(jdk) / "bin") + os.pathsep + e.get("PATH", "")
|
|
110
|
+
return e
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------- araç kurulumu (yalnızca eksikse)
|
|
114
|
+
def ensure_jdk() -> Path:
|
|
115
|
+
def major(javac):
|
|
116
|
+
try:
|
|
117
|
+
p = subprocess.run([str(javac), "-version"], capture_output=True, text=True)
|
|
118
|
+
return int(re.search(r"(\d+)", p.stdout + p.stderr).group(1))
|
|
119
|
+
except Exception:
|
|
120
|
+
return 0
|
|
121
|
+
cands = []
|
|
122
|
+
if os.environ.get("JAVA_HOME"):
|
|
123
|
+
cands.append(Path(os.environ["JAVA_HOME"]) / "bin" / f"javac{EXE}")
|
|
124
|
+
if shutil.which("javac"):
|
|
125
|
+
cands.append(Path(shutil.which("javac")).resolve())
|
|
126
|
+
for c in cands:
|
|
127
|
+
if c.exists() and major(c) >= 17:
|
|
128
|
+
return c.parent.parent
|
|
129
|
+
d = HOME / "jdk"
|
|
130
|
+
found = list(d.glob(f"**/bin/javac{EXE}"))
|
|
131
|
+
if not found:
|
|
132
|
+
osn = {"Windows": "windows", "Darwin": "mac"}.get(SYS, "linux")
|
|
133
|
+
a = "aarch64" if ARCH == "aarch64" else "x64"
|
|
134
|
+
ext = "zip" if WIN else "tar.gz"
|
|
135
|
+
f = download(f"https://api.adoptium.net/v3/binary/latest/17/ga/{osn}/{a}/jdk/hotspot/normal/eclipse",
|
|
136
|
+
HOME / "dl" / f"jdk17-{osn}-{a}.{ext}")
|
|
137
|
+
extract(f, d)
|
|
138
|
+
found = list(d.glob(f"**/bin/javac{EXE}"))
|
|
139
|
+
return found[0].parent.parent
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def ensure_sdk(jdk) -> Path:
|
|
143
|
+
sdk = HOME / "sdk"
|
|
144
|
+
sm = sdk / "cmdline-tools" / "latest" / "bin" / f"sdkmanager{BAT}"
|
|
145
|
+
if not sm.exists():
|
|
146
|
+
osn = {"Windows": "win", "Darwin": "mac"}.get(SYS, "linux")
|
|
147
|
+
z = download(CMDLINE.format(os=osn), HOME / "dl" / f"cmdline-tools-{osn}.zip")
|
|
148
|
+
tmp = HOME / "tmp_cmd"; shutil.rmtree(tmp, ignore_errors=True); extract(z, tmp)
|
|
149
|
+
(sdk / "cmdline-tools").mkdir(parents=True, exist_ok=True)
|
|
150
|
+
shutil.move(str(tmp / "cmdline-tools"), str(sm.parent.parent))
|
|
151
|
+
want = []
|
|
152
|
+
if not (sdk / "build-tools" / BT_VER / f"aapt2{EXE}").exists(): want.append(f"build-tools;{BT_VER}")
|
|
153
|
+
if not (sdk / "platforms" / PLATFORM / "android.jar").exists(): want.append(f"platforms;{PLATFORM}")
|
|
154
|
+
if want:
|
|
155
|
+
log("Android SDK lisansları otomatik kabul ediliyor: https://developer.android.com/studio/terms")
|
|
156
|
+
run([sm, f"--sdk_root={sdk}", *want], input="y\n" * 60, env=jenv(jdk))
|
|
157
|
+
return sdk
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --------------------------------------------------------------------------- hazır parçalar (GitHub Releases)
|
|
161
|
+
def asset_url(repo, tag, name):
|
|
162
|
+
return f"https://github.com/{repo}/releases/download/{tag}/{urllib.parse.quote(name)}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def fetch_index(repo):
|
|
166
|
+
try:
|
|
167
|
+
with urllib.request.urlopen(asset_url(repo, PY_TAG, "index.json"), timeout=30) as r:
|
|
168
|
+
return json.load(r)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
die(f"Kütüphane dizini alınamadı ({repo}, etiket {PY_TAG}): {e}\n"
|
|
171
|
+
"GitHub deposunda 'factory' iş akışının çalışıp release oluşturduğundan emin olun.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def lib_status(idx, lib):
|
|
175
|
+
if idx is None:
|
|
176
|
+
return "…"
|
|
177
|
+
e = idx["libs"].get(lib)
|
|
178
|
+
if e:
|
|
179
|
+
return "✓ hazır (derlenmiş)" if e["native"] else "✓ hazır (saf Python)"
|
|
180
|
+
if lib in idx.get("failed", {}):
|
|
181
|
+
return "✗ " + idx["failed"][lib][:60]
|
|
182
|
+
return "dizinde yok → pip denenecek"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --------------------------------------------------------------------------- uygulama içeriği
|
|
186
|
+
def collect(main: Path, extra=()):
|
|
187
|
+
items = []
|
|
188
|
+
for p in sorted(main.parent.iterdir()):
|
|
189
|
+
if p.name.startswith((".", "_pyapk")):
|
|
190
|
+
continue
|
|
191
|
+
if p.is_file() and p.suffix == ".py":
|
|
192
|
+
items.append(p)
|
|
193
|
+
elif p.is_dir() and (p / "__init__.py").exists() and p.name not in {"venv", "env", "build", "dist"}:
|
|
194
|
+
items.append(p)
|
|
195
|
+
return items + [Path(e).resolve() for e in extra]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def detect_libs(items):
|
|
199
|
+
local = {p.stem if p.is_file() else p.name for p in items}
|
|
200
|
+
found = set()
|
|
201
|
+
for p in items:
|
|
202
|
+
for f in ([p] if p.is_file() else p.rglob("*.py")):
|
|
203
|
+
try:
|
|
204
|
+
tree = ast.parse(f.read_text(encoding="utf-8", errors="replace"))
|
|
205
|
+
except (SyntaxError, OSError):
|
|
206
|
+
continue
|
|
207
|
+
for n in ast.walk(tree):
|
|
208
|
+
if isinstance(n, ast.Import):
|
|
209
|
+
found |= {a.name.split(".")[0] for a in n.names}
|
|
210
|
+
elif isinstance(n, ast.ImportFrom) and n.level == 0 and n.module:
|
|
211
|
+
found.add(n.module.split(".")[0])
|
|
212
|
+
for m, why in UNSUPPORTED.items():
|
|
213
|
+
if m in found:
|
|
214
|
+
log(f"UYARI: '{m}' kullanılıyor ama {why}")
|
|
215
|
+
return sorted({norm(PIPMAP.get(m, m)) for m in found - set(sys.stdlib_module_names) - local})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def pip_fallback(libs, site, abi, py):
|
|
219
|
+
cmd = [sys.executable, "-m", "pip", "install", "--target", site, "--only-binary=:all:", "--no-compile",
|
|
220
|
+
"--disable-pip-version-check", "--implementation", "cp", "--python-version", py,
|
|
221
|
+
"--abi", "cp" + py.replace(".", "")]
|
|
222
|
+
api_tag = abi.replace("-", "_")
|
|
223
|
+
for api in range(21, MIN_SDK + 1):
|
|
224
|
+
cmd += ["--platform", f"android_{api}_{api_tag}"]
|
|
225
|
+
for lib in libs: # tek tek: biri olmazsa diğerleri yine gelsin
|
|
226
|
+
try:
|
|
227
|
+
run(cmd + [lib])
|
|
228
|
+
except Fail:
|
|
229
|
+
log(f"UYARI: '{lib}' için Android/saf-Python wheel bulunamadı, atlandı")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def make_icon(icon, dest: Path):
|
|
233
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
if icon:
|
|
235
|
+
try:
|
|
236
|
+
from PIL import Image
|
|
237
|
+
Image.open(icon).convert("RGBA").resize((192, 192)).save(dest); return
|
|
238
|
+
except ImportError:
|
|
239
|
+
if Path(icon).suffix.lower() != ".png":
|
|
240
|
+
die("PNG olmayan logo için Pillow gerekir: pip install pillow")
|
|
241
|
+
shutil.copy(icon, dest); return
|
|
242
|
+
n = 192
|
|
243
|
+
raw = (b"\x00" + bytes([0x30, 0x6E, 0xB8, 255]) * n) * n
|
|
244
|
+
ch = lambda t, d: struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d))
|
|
245
|
+
dest.write_bytes(b"\x89PNG\r\n\x1a\n" + ch(b"IHDR", struct.pack(">IIBBBBB", n, n, 8, 6, 0, 0, 0))
|
|
246
|
+
+ ch(b"IDAT", zlib.compress(raw)) + ch(b"IEND", b""))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def manifest_xml(pkg, name, vname, vcode, mode="console", port=5000):
|
|
250
|
+
return f'''<?xml version="1.0" encoding="utf-8"?>
|
|
251
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package={quoteattr(pkg)}
|
|
252
|
+
android:versionCode="{vcode}" android:versionName={quoteattr(vname)}>
|
|
253
|
+
<uses-permission android:name="android.permission.INTERNET"/>
|
|
254
|
+
<application android:label={quoteattr(name)} android:icon="@mipmap/ic_launcher" android:allowBackup="false"
|
|
255
|
+
android:extractNativeLibs="true" android:usesCleartextTraffic="true"
|
|
256
|
+
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
|
257
|
+
<meta-data android:name="py2apk.mode" android:value={quoteattr(mode)}/>
|
|
258
|
+
<meta-data android:name="py2apk.port" android:value={quoteattr(str(port))}/>
|
|
259
|
+
<activity android:name="pyapk.runtime.MainActivity" android:exported="true"
|
|
260
|
+
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize|uiMode">
|
|
261
|
+
<intent-filter>
|
|
262
|
+
<action android:name="android.intent.action.MAIN"/>
|
|
263
|
+
<category android:name="android.intent.category.LAUNCHER"/>
|
|
264
|
+
</intent-filter>
|
|
265
|
+
</activity>
|
|
266
|
+
</application>
|
|
267
|
+
</manifest>
|
|
268
|
+
'''
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
BOOT_PY = r'''import sys, os, runpy, traceback
|
|
272
|
+
sys.stdout = open(1, "w", buffering=1, encoding="utf-8", errors="replace", closefd=False)
|
|
273
|
+
sys.stderr = open(2, "w", buffering=1, encoding="utf-8", errors="replace", closefd=False)
|
|
274
|
+
try:
|
|
275
|
+
import certifi
|
|
276
|
+
os.environ.setdefault("SSL_CERT_FILE", certifi.where())
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
os.environ["PORT"] = "@PORT@"
|
|
280
|
+
_main = os.path.join(os.path.dirname(os.path.abspath(__file__)), @MAIN@)
|
|
281
|
+
sys.argv = [_main]
|
|
282
|
+
try:
|
|
283
|
+
runpy.run_path(_main, run_name="__main__")
|
|
284
|
+
except SystemExit:
|
|
285
|
+
pass
|
|
286
|
+
except BaseException:
|
|
287
|
+
traceback.print_exc()
|
|
288
|
+
print("\n[program sona erdi]")
|
|
289
|
+
'''
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --------------------------------------------------------------------------- APK üretimi
|
|
293
|
+
def build_apk(c) -> Path:
|
|
294
|
+
"""c: script, name, package, icon, version, vcode, abis, libs(list), repo, add(list)"""
|
|
295
|
+
if c.get("mode", "console") not in ("console", "webview"):
|
|
296
|
+
die("Bu uygulama türü henüz desteklenmiyor")
|
|
297
|
+
idx = fetch_index(c["repo"])
|
|
298
|
+
py, tag, bid = idx["py"], idx["tag"], idx["build"]
|
|
299
|
+
for abi in c["abis"]:
|
|
300
|
+
if abi not in idx["abis"]:
|
|
301
|
+
die(f"Release'te {abi} runtime'ı yok")
|
|
302
|
+
src = Path(c["script"]).resolve()
|
|
303
|
+
items = collect(src, c.get("add", []))
|
|
304
|
+
libs = list(dict.fromkeys([*c["libs"], "certifi"]))
|
|
305
|
+
log(f"uygulama: {c['name']} ({c['package']}) ABI: {c['abis']} kütüphaneler: {libs}")
|
|
306
|
+
|
|
307
|
+
jdk = ensure_jdk(); sdk = ensure_sdk(jdk); je = jenv(jdk)
|
|
308
|
+
bt, android_jar = sdk / "build-tools" / BT_VER, sdk / "platforms" / PLATFORM / "android.jar"
|
|
309
|
+
work = HOME / "work" / c["package"]; shutil.rmtree(work, ignore_errors=True); work.mkdir(parents=True)
|
|
310
|
+
|
|
311
|
+
make_icon(c.get("icon"), work / "res" / "mipmap-xxxhdpi" / "ic_launcher.png")
|
|
312
|
+
(work / "AndroidManifest.xml").write_text(manifest_xml(c["package"], c["name"], c["version"], c["vcode"],
|
|
313
|
+
c.get("mode", "console"), c.get("port", 5000)),
|
|
314
|
+
encoding="utf-8")
|
|
315
|
+
run([bt / f"aapt2{EXE}", "compile", "--dir", work / "res", "-o", work / "res.zip"])
|
|
316
|
+
base = work / "base.apk"
|
|
317
|
+
run([bt / f"aapt2{EXE}", "link", "-o", base, "-I", android_jar, "--manifest", work / "AndroidManifest.xml",
|
|
318
|
+
"--min-sdk-version", MIN_SDK, "--target-sdk-version", TARGET_SDK, "--version-code", c["vcode"],
|
|
319
|
+
"--version-name", c["version"], work / "res.zip"])
|
|
320
|
+
|
|
321
|
+
dl = HOME / "dl" / bid
|
|
322
|
+
dex = download(asset_url(c["repo"], tag, f"classes-{tag}.dex"), dl / "classes.dex")
|
|
323
|
+
parts = {}
|
|
324
|
+
for abi in c["abis"]:
|
|
325
|
+
rt = HOME / "runtime" / bid / abi
|
|
326
|
+
if not rt.exists():
|
|
327
|
+
extract(download(asset_url(c["repo"], tag, f"runtime-{tag}-{abi}.zip"), dl / f"runtime-{abi}.zip"), rt)
|
|
328
|
+
root = work / f"home-{abi}"
|
|
329
|
+
shutil.copytree(rt / "home", root)
|
|
330
|
+
site = root / "lib" / f"python{py}" / "site-packages"; site.mkdir(parents=True, exist_ok=True)
|
|
331
|
+
missing, done = [], set()
|
|
332
|
+
for lib in libs:
|
|
333
|
+
ent = idx["libs"].get(lib)
|
|
334
|
+
if not ent or abi not in ent["files"]:
|
|
335
|
+
missing.append(lib); continue
|
|
336
|
+
for fn in ent["files"][abi]:
|
|
337
|
+
if fn not in done:
|
|
338
|
+
done.add(fn)
|
|
339
|
+
with zipfile.ZipFile(download(asset_url(c["repo"], tag, fn), dl / fn)) as z:
|
|
340
|
+
z.extractall(site)
|
|
341
|
+
if missing:
|
|
342
|
+
log(f"dizinde olmayanlar pip ile deneniyor: {missing}")
|
|
343
|
+
pip_fallback(missing, site, abi, py)
|
|
344
|
+
app = root / "app"; app.mkdir()
|
|
345
|
+
for it in items:
|
|
346
|
+
if it.is_dir():
|
|
347
|
+
shutil.copytree(it, app / it.name, ignore=shutil.ignore_patterns("__pycache__"))
|
|
348
|
+
else:
|
|
349
|
+
shutil.copy2(it, app / it.name)
|
|
350
|
+
boot = BOOT_PY.replace("@MAIN@", repr(src.name)).replace("@PORT@", str(c.get("port", 5000)))
|
|
351
|
+
(app / "_pyapk_boot.py").write_text(boot, encoding="utf-8")
|
|
352
|
+
z = work / f"pyhome-{abi}.zip"
|
|
353
|
+
with zipfile.ZipFile(z, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
354
|
+
for p in sorted(root.rglob("*")):
|
|
355
|
+
if p.is_file():
|
|
356
|
+
zf.write(p, p.relative_to(root).as_posix())
|
|
357
|
+
parts[abi] = (sorted((rt / "libs").glob("*.so")), z)
|
|
358
|
+
|
|
359
|
+
with zipfile.ZipFile(base, "a") as z:
|
|
360
|
+
z.write(dex, "classes.dex", zipfile.ZIP_DEFLATED)
|
|
361
|
+
for abi, (sos, home_zip) in parts.items():
|
|
362
|
+
for so in sos:
|
|
363
|
+
z.write(so, f"lib/{abi}/{so.name}", zipfile.ZIP_STORED)
|
|
364
|
+
z.write(home_zip, f"assets/pyhome-{abi}.zip", zipfile.ZIP_STORED)
|
|
365
|
+
|
|
366
|
+
ks = HOME / "debug.keystore"
|
|
367
|
+
if not ks.exists():
|
|
368
|
+
run([jdk / "bin" / f"keytool{EXE}", "-genkeypair", "-keystore", ks, "-storepass", "android",
|
|
369
|
+
"-keypass", "android", "-alias", "pyapk", "-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
|
|
370
|
+
"-dname", "CN=py2apk"])
|
|
371
|
+
aligned = work / "aligned.apk"
|
|
372
|
+
run([bt / f"zipalign{EXE}", "-f", "-p", "4", base, aligned])
|
|
373
|
+
out = Path(c.get("out") or src.parent / f"{re.sub(r'[^A-Za-z0-9_-]', '_', c['name'])}-{c['version']}.apk")
|
|
374
|
+
run([bt / f"apksigner{BAT}", "sign", "--ks", ks, "--ks-pass", "pass:android", "--key-pass", "pass:android",
|
|
375
|
+
"--out", out, aligned], env=je)
|
|
376
|
+
log(f"HAZIR: {out} ({out.stat().st_size >> 20} MB)")
|
|
377
|
+
return out
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# --------------------------------------------------------------------------- GUI
|
|
381
|
+
BG, HEAD, CARD, CARD2 = "#0b1220", "#0f1830", "#141c2f", "#18233b"
|
|
382
|
+
FIELD, LINE, FG, MUTED = "#0c1426", "#26334f", "#e6edf7", "#8b9ab3"
|
|
383
|
+
ACC, OK, WARN, BAD = "#5b8cff", "#34d399", "#fbbf24", "#f87171"
|
|
384
|
+
UI = {"Windows": "Segoe UI", "Darwin": "Helvetica Neue"}.get(SYS, "DejaVu Sans")
|
|
385
|
+
MONO = {"Windows": "Consolas", "Darwin": "Menlo"}.get(SYS, "DejaVu Sans Mono")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def open_folder(p):
|
|
389
|
+
if WIN:
|
|
390
|
+
os.startfile(p) # noqa
|
|
391
|
+
else:
|
|
392
|
+
subprocess.run(["open" if SYS == "Darwin" else "xdg-open", str(p)])
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def main(argv=None):
|
|
396
|
+
try:
|
|
397
|
+
import tkinter as tk
|
|
398
|
+
from tkinter import filedialog, messagebox, ttk
|
|
399
|
+
except ImportError:
|
|
400
|
+
sys.exit("tkinter bulunamadı. Linux'ta: sudo apt install python3-tk")
|
|
401
|
+
if WIN:
|
|
402
|
+
try:
|
|
403
|
+
import ctypes
|
|
404
|
+
ctypes.windll.shcore.SetProcessDpiAwareness(1) # net yazı
|
|
405
|
+
except Exception:
|
|
406
|
+
pass
|
|
407
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
408
|
+
|
|
409
|
+
class App(tk.Tk):
|
|
410
|
+
def __init__(self):
|
|
411
|
+
super().__init__()
|
|
412
|
+
self.title("py2apk"); self.geometry("1100x790"); self.minsize(1000, 720); self.configure(bg=BG)
|
|
413
|
+
self.q, self.idx, self.detected, self.photo = queue.Queue(), None, [], None
|
|
414
|
+
self.script, self.name, self.pkg, self.icon = (tk.StringVar() for _ in range(4))
|
|
415
|
+
self.version, self.extra, self.repo = tk.StringVar(value="1.0"), tk.StringVar(), tk.StringVar(value=REPO)
|
|
416
|
+
self.port, self.mode, self.status = tk.StringVar(value="5000"), tk.StringVar(value="console"), \
|
|
417
|
+
tk.StringVar(value="Hazır")
|
|
418
|
+
self.abi = {a: tk.BooleanVar(value=(a == "arm64-v8a")) for a in ABIS}
|
|
419
|
+
self.cards = {}
|
|
420
|
+
self.style_setup(); self.build_ui(); self.set_mode("console")
|
|
421
|
+
self.extra.trace_add("write", lambda *_: self.refresh())
|
|
422
|
+
set_log(lambda m: self.q.put(("log", m)))
|
|
423
|
+
self.after(100, self.pump)
|
|
424
|
+
threading.Thread(target=self.load_index, daemon=True).start()
|
|
425
|
+
if argv and Path(argv[0]).is_file():
|
|
426
|
+
self.script.set(argv[0]); self.on_script()
|
|
427
|
+
|
|
428
|
+
# ---- görünüm
|
|
429
|
+
def style_setup(self):
|
|
430
|
+
s = ttk.Style(self); s.theme_use("clam")
|
|
431
|
+
s.configure(".", background=BG, foreground=FG, font=(UI, 10))
|
|
432
|
+
s.configure("T.TEntry", fieldbackground=FIELD, foreground=FG, bordercolor=LINE, lightcolor=LINE,
|
|
433
|
+
darkcolor=LINE, insertcolor=FG, padding=7)
|
|
434
|
+
s.map("T.TEntry", bordercolor=[("focus", ACC)], lightcolor=[("focus", ACC)], darkcolor=[("focus", ACC)])
|
|
435
|
+
s.configure("T.TButton", background=LINE, foreground=FG, borderwidth=0, focusthickness=0, padding=(14, 7))
|
|
436
|
+
s.map("T.TButton", background=[("active", "#2f3d5e")])
|
|
437
|
+
s.configure("Accent.TButton", background=ACC, foreground="#ffffff", font=(UI, 12, "bold"),
|
|
438
|
+
padding=(18, 12), borderwidth=0, focusthickness=0)
|
|
439
|
+
s.map("Accent.TButton", background=[("disabled", "#26324d"), ("active", "#7aa2ff")],
|
|
440
|
+
foreground=[("disabled", "#7c8aa5")])
|
|
441
|
+
s.configure("T.Horizontal.TProgressbar", troughcolor=FIELD, background=ACC, bordercolor=BG,
|
|
442
|
+
lightcolor=ACC, darkcolor=ACC, thickness=8)
|
|
443
|
+
s.configure("T.Treeview", background=FIELD, fieldbackground=FIELD, foreground=FG, rowheight=27,
|
|
444
|
+
borderwidth=0, font=(UI, 10))
|
|
445
|
+
s.configure("T.Treeview.Heading", background=CARD2, foreground=MUTED, borderwidth=0, relief="flat",
|
|
446
|
+
font=(UI, 9, "bold"))
|
|
447
|
+
s.map("T.Treeview", background=[("selected", "#22304f")])
|
|
448
|
+
s.map("T.Treeview.Heading", background=[("active", CARD2)])
|
|
449
|
+
s.configure("T.TCheckbutton", background=CARD, foreground=FG, font=(UI, 10), focusthickness=0)
|
|
450
|
+
s.map("T.TCheckbutton", background=[("active", CARD)])
|
|
451
|
+
s.configure("Vertical.TScrollbar", background=LINE, troughcolor=FIELD, bordercolor=FIELD,
|
|
452
|
+
arrowcolor=MUTED, relief="flat")
|
|
453
|
+
|
|
454
|
+
def card(self, parent, step, title):
|
|
455
|
+
outer = tk.Frame(parent, bg=CARD, highlightthickness=1, highlightbackground=LINE)
|
|
456
|
+
head = tk.Frame(outer, bg=CARD); head.pack(fill="x", padx=16, pady=(14, 8))
|
|
457
|
+
tk.Label(head, text=str(step), bg=ACC, fg="#fff", font=(UI, 9, "bold"), width=2).pack(side="left")
|
|
458
|
+
tk.Label(head, text=title, bg=CARD, fg=FG, font=(UI, 11, "bold")).pack(side="left", padx=10)
|
|
459
|
+
body = tk.Frame(outer, bg=CARD); body.pack(fill="both", expand=True, padx=16, pady=(0, 14))
|
|
460
|
+
return outer, body
|
|
461
|
+
|
|
462
|
+
def label(self, parent, text, **kw):
|
|
463
|
+
return tk.Label(parent, text=text, bg=kw.pop("bg", CARD), fg=kw.pop("fg", MUTED),
|
|
464
|
+
font=kw.pop("font", (UI, 9)), anchor="w", **kw)
|
|
465
|
+
|
|
466
|
+
def field(self, body, row, text, var, browse=None, width=None):
|
|
467
|
+
self.label(body, text).grid(row=row, column=0, sticky="w", pady=4, padx=(0, 10))
|
|
468
|
+
ttk.Entry(body, textvariable=var, style="T.TEntry", width=width or 30).grid(row=row, column=1,
|
|
469
|
+
sticky="ew", pady=4)
|
|
470
|
+
if browse:
|
|
471
|
+
ttk.Button(body, text="Seç…", style="T.TButton", command=browse).grid(row=row, column=2,
|
|
472
|
+
padx=(8, 0))
|
|
473
|
+
|
|
474
|
+
def build_ui(self):
|
|
475
|
+
hd = tk.Frame(self, bg=HEAD); hd.pack(fill="x")
|
|
476
|
+
inner = tk.Frame(hd, bg=HEAD); inner.pack(fill="x", padx=24, pady=16)
|
|
477
|
+
tk.Label(inner, text="py2", bg=HEAD, fg=ACC, font=(UI, 22, "bold")).pack(side="left")
|
|
478
|
+
tk.Label(inner, text="apk", bg=HEAD, fg=FG, font=(UI, 22, "bold")).pack(side="left")
|
|
479
|
+
tk.Label(inner, text="Python'u Android uygulamasına çevir", bg=HEAD, fg=MUTED,
|
|
480
|
+
font=(UI, 11)).pack(side="left", padx=16, pady=(8, 0))
|
|
481
|
+
tk.Frame(self, bg=LINE, height=1).pack(fill="x")
|
|
482
|
+
|
|
483
|
+
foot = tk.Frame(self, bg=BG); foot.pack(side="bottom", fill="x", padx=20, pady=(6, 16))
|
|
484
|
+
self.btn = ttk.Button(foot, text="APK OLUŞTUR", style="Accent.TButton", command=self.start)
|
|
485
|
+
self.btn.pack(side="left")
|
|
486
|
+
right = tk.Frame(foot, bg=BG); right.pack(side="left", fill="x", expand=True, padx=18)
|
|
487
|
+
tk.Label(right, textvariable=self.status, bg=BG, fg=MUTED, font=(UI, 9), anchor="w").pack(fill="x")
|
|
488
|
+
self.pb = ttk.Progressbar(right, mode="indeterminate", style="T.Horizontal.TProgressbar")
|
|
489
|
+
self.pb.pack(fill="x", pady=(4, 0))
|
|
490
|
+
|
|
491
|
+
body = tk.Frame(self, bg=BG); body.pack(fill="both", expand=True, padx=20, pady=16)
|
|
492
|
+
body.columnconfigure(0, weight=11, uniform="c"); body.columnconfigure(1, weight=9, uniform="c")
|
|
493
|
+
body.rowconfigure(0, weight=1)
|
|
494
|
+
left = tk.Frame(body, bg=BG); left.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
|
|
495
|
+
rt = tk.Frame(body, bg=BG); rt.grid(row=0, column=1, sticky="nsew", padx=(10, 0))
|
|
496
|
+
|
|
497
|
+
# 1 kaynak
|
|
498
|
+
c, b = self.card(left, 1, "Python dosyası"); c.pack(fill="x", pady=(0, 12)); b.columnconfigure(1, weight=1)
|
|
499
|
+
self.field(b, 0, "Giriş dosyası", self.script, self.pick_script)
|
|
500
|
+
|
|
501
|
+
# 2 uygulama bilgisi
|
|
502
|
+
c, b = self.card(left, 2, "Uygulama"); c.pack(fill="x", pady=(0, 12)); b.columnconfigure(1, weight=1)
|
|
503
|
+
self.field(b, 0, "Ad", self.name)
|
|
504
|
+
self.field(b, 1, "Paket adı", self.pkg)
|
|
505
|
+
self.field(b, 2, "Sürüm", self.version)
|
|
506
|
+
self.field(b, 3, "Logo", self.icon, self.pick_icon)
|
|
507
|
+
self.preview = tk.Label(b, text="logo", bg=FIELD, fg=MUTED, width=8, height=4, font=(UI, 8))
|
|
508
|
+
self.preview.grid(row=0, column=3, rowspan=3, padx=(14, 0), sticky="ne")
|
|
509
|
+
|
|
510
|
+
# 3 uygulama türü
|
|
511
|
+
c, b = self.card(left, 3, "Uygulama türü"); c.pack(fill="both", expand=True)
|
|
512
|
+
grid = tk.Frame(b, bg=CARD); grid.pack(fill="x")
|
|
513
|
+
for i in range(2):
|
|
514
|
+
grid.columnconfigure(i, weight=1, uniform="m")
|
|
515
|
+
for n, (key, title, desc, ok) in enumerate(MODES):
|
|
516
|
+
self.make_card(grid, key, title, desc, ok).grid(row=n // 2, column=n % 2, sticky="nsew",
|
|
517
|
+
padx=4, pady=4)
|
|
518
|
+
self.mode_box = tk.Frame(b, bg=CARD); self.mode_box.pack(fill="x", pady=(8, 0))
|
|
519
|
+
self.mode_hint = tk.Label(self.mode_box, bg=CARD, fg=MUTED, font=(UI, 9), justify="left",
|
|
520
|
+
anchor="w", wraplength=470)
|
|
521
|
+
self.mode_hint.pack(fill="x")
|
|
522
|
+
self.port_row = tk.Frame(self.mode_box, bg=CARD)
|
|
523
|
+
self.label(self.port_row, "Sunucu portu").pack(side="left")
|
|
524
|
+
ttk.Entry(self.port_row, textvariable=self.port, style="T.TEntry", width=7).pack(side="left", padx=8)
|
|
525
|
+
self.code_hint = tk.Label(self.mode_box, bg=FIELD, fg="#b9c7e3", font=(MONO, 9), justify="left",
|
|
526
|
+
anchor="w", padx=10, pady=8,
|
|
527
|
+
text="import os\napp.run(host=\"127.0.0.1\",\n port=int(os.environ.get(\"PORT\", 5000)),\n debug=False)")
|
|
528
|
+
|
|
529
|
+
# 4 kütüphaneler
|
|
530
|
+
c, b = self.card(rt, 4, "Kütüphaneler"); c.pack(fill="x", pady=(0, 12))
|
|
531
|
+
self.tree = ttk.Treeview(b, columns=("lib", "durum"), show="headings", height=5, style="T.Treeview")
|
|
532
|
+
self.tree.heading("lib", text="Algılanan", anchor="w"); self.tree.heading("durum", text="Durum", anchor="w")
|
|
533
|
+
self.tree.column("lib", width=150); self.tree.column("durum", width=260)
|
|
534
|
+
for t, col in (("ok", OK), ("warn", WARN), ("bad", BAD)):
|
|
535
|
+
self.tree.tag_configure(t, foreground=col)
|
|
536
|
+
self.tree.pack(fill="x")
|
|
537
|
+
r = tk.Frame(b, bg=CARD); r.pack(fill="x", pady=(10, 0)); r.columnconfigure(1, weight=1)
|
|
538
|
+
self.field(r, 0, "Ek paketler", self.extra, width=20)
|
|
539
|
+
ab = tk.Frame(b, bg=CARD); ab.pack(fill="x", pady=(8, 0))
|
|
540
|
+
for a, v in self.abi.items():
|
|
541
|
+
ttk.Checkbutton(ab, text=a + (" · telefon" if a == "arm64-v8a" else " · emülatör"), variable=v,
|
|
542
|
+
style="T.TCheckbutton").pack(side="left", padx=(0, 16))
|
|
543
|
+
adv = tk.Label(b, text="Gelişmiş ▸", bg=CARD, fg=MUTED, font=(UI, 9), cursor="hand2")
|
|
544
|
+
adv.pack(anchor="w", pady=(8, 0))
|
|
545
|
+
self.adv_row = tk.Frame(b, bg=CARD); self.adv_row.columnconfigure(1, weight=1)
|
|
546
|
+
self.field(self.adv_row, 0, "GitHub deposu", self.repo, width=20)
|
|
547
|
+
adv.bind("<Button-1>", lambda e: (self.adv_row.pack_forget() if self.adv_row.winfo_ismapped()
|
|
548
|
+
else self.adv_row.pack(fill="x")))
|
|
549
|
+
|
|
550
|
+
# günlük
|
|
551
|
+
c, b = self.card(rt, "›", "Günlük"); c.pack(fill="both", expand=True)
|
|
552
|
+
wrap = tk.Frame(b, bg="#070c16"); wrap.pack(fill="both", expand=True)
|
|
553
|
+
self.logw = tk.Text(wrap, height=8, state="disabled", bg="#070c16", fg="#c9d4e8", relief="flat",
|
|
554
|
+
font=(MONO, 9), wrap="word", padx=10, pady=8, insertbackground=FG,
|
|
555
|
+
highlightthickness=0)
|
|
556
|
+
sb = ttk.Scrollbar(wrap, command=self.logw.yview)
|
|
557
|
+
self.logw["yscrollcommand"] = sb.set
|
|
558
|
+
sb.pack(side="right", fill="y"); self.logw.pack(side="left", fill="both", expand=True)
|
|
559
|
+
self.logw.tag_configure("cmd", foreground="#6b7a99"); self.logw.tag_configure("warn", foreground=WARN)
|
|
560
|
+
self.logw.tag_configure("good", foreground=OK, font=(MONO, 9, "bold"))
|
|
561
|
+
self.logw.tag_configure("bad", foreground=BAD)
|
|
562
|
+
|
|
563
|
+
def make_card(self, parent, key, title, desc, ok):
|
|
564
|
+
fr = tk.Frame(parent, bg=CARD2, highlightthickness=2, highlightbackground=LINE,
|
|
565
|
+
cursor="hand2" if ok else "arrow")
|
|
566
|
+
top = tk.Frame(fr, bg=CARD2); top.pack(fill="x", padx=10, pady=(10, 2))
|
|
567
|
+
t = tk.Label(top, text=title, bg=CARD2, fg=FG if ok else MUTED, font=(UI, 11, "bold"), anchor="w")
|
|
568
|
+
t.pack(side="left")
|
|
569
|
+
if not ok:
|
|
570
|
+
tk.Label(top, text="YAKINDA", bg=WARN, fg="#1a1300", font=(UI, 7, "bold"), padx=5).pack(side="right")
|
|
571
|
+
d = tk.Label(fr, text=desc, bg=CARD2, fg=MUTED, font=(UI, 9), justify="left", anchor="nw",
|
|
572
|
+
wraplength=210, height=4)
|
|
573
|
+
d.pack(fill="x", padx=10, pady=(0, 10))
|
|
574
|
+
if ok:
|
|
575
|
+
for w in (fr, top, t, d):
|
|
576
|
+
w.bind("<Button-1>", lambda e, k=key: self.set_mode(k))
|
|
577
|
+
self.cards[key] = fr
|
|
578
|
+
return fr
|
|
579
|
+
|
|
580
|
+
def set_mode(self, key):
|
|
581
|
+
self.mode.set(key)
|
|
582
|
+
for k, fr in self.cards.items():
|
|
583
|
+
fr.configure(highlightbackground=ACC if k == key else LINE)
|
|
584
|
+
if key == "webview":
|
|
585
|
+
self.mode_hint.configure(text="Kodunuz bir web sunucusu başlatmalı (debug kapalı). "
|
|
586
|
+
"Hazır olunca arayüz uygulamada tam ekran açılır.")
|
|
587
|
+
self.port_row.pack(anchor="w", pady=(8, 0)); self.code_hint.pack(fill="x", pady=(8, 0))
|
|
588
|
+
else:
|
|
589
|
+
self.mode_hint.configure(text="Çıktı ekranda metin olarak görünür. Etkileşimli girdi (input) yok.")
|
|
590
|
+
self.port_row.pack_forget(); self.code_hint.pack_forget()
|
|
591
|
+
|
|
592
|
+
# ---- olaylar
|
|
593
|
+
def pick_script(self):
|
|
594
|
+
p = filedialog.askopenfilename(title="Python dosyası", filetypes=[("Python", "*.py")])
|
|
595
|
+
if p:
|
|
596
|
+
self.script.set(p); self.on_script()
|
|
597
|
+
|
|
598
|
+
def pick_icon(self):
|
|
599
|
+
p = filedialog.askopenfilename(title="Logo", filetypes=[("Resim", "*.png *.jpg *.jpeg *.webp")])
|
|
600
|
+
if not p:
|
|
601
|
+
return
|
|
602
|
+
self.icon.set(p)
|
|
603
|
+
try:
|
|
604
|
+
try:
|
|
605
|
+
from PIL import Image, ImageTk
|
|
606
|
+
self.photo = ImageTk.PhotoImage(Image.open(p).convert("RGBA").resize((64, 64)))
|
|
607
|
+
except ImportError:
|
|
608
|
+
img = tk.PhotoImage(file=p)
|
|
609
|
+
self.photo = img.subsample(max(1, img.width() // 64), max(1, img.height() // 64))
|
|
610
|
+
self.preview.configure(image=self.photo, text="", width=64, height=64)
|
|
611
|
+
except Exception:
|
|
612
|
+
self.preview.configure(image="", text="logo")
|
|
613
|
+
|
|
614
|
+
def on_script(self):
|
|
615
|
+
p = Path(self.script.get())
|
|
616
|
+
stem = re.sub(r"[^a-z0-9_]", "_", p.stem.lower()) or "app"
|
|
617
|
+
self.name.set(p.stem.replace("_", " ").title()); self.pkg.set(f"com.py2apk.{stem}")
|
|
618
|
+
try:
|
|
619
|
+
self.detected = detect_libs(collect(p))
|
|
620
|
+
except Exception as e:
|
|
621
|
+
self.detected = []; log(f"tarama hatası: {e}")
|
|
622
|
+
if WEB_LIBS & set(self.detected):
|
|
623
|
+
self.set_mode("webview")
|
|
624
|
+
log("Web çatısı algılandı → uygulama türü 'Web arayüzü' seçildi")
|
|
625
|
+
self.refresh()
|
|
626
|
+
|
|
627
|
+
def load_index(self):
|
|
628
|
+
try:
|
|
629
|
+
self.idx = fetch_index(self.repo.get())
|
|
630
|
+
except Fail as e:
|
|
631
|
+
self.q.put(("log", f"UYARI: {e}"))
|
|
632
|
+
self.q.put(("refresh", None))
|
|
633
|
+
|
|
634
|
+
def libs(self):
|
|
635
|
+
extra = [norm(x) for x in re.split(r"[,\s]+", self.extra.get()) if x]
|
|
636
|
+
return list(dict.fromkeys([*self.detected, *extra]))
|
|
637
|
+
|
|
638
|
+
def refresh(self):
|
|
639
|
+
self.tree.delete(*self.tree.get_children())
|
|
640
|
+
for lib in self.libs():
|
|
641
|
+
st = lib_status(self.idx, lib)
|
|
642
|
+
self.tree.insert("", "end", values=(lib, st),
|
|
643
|
+
tags=("ok" if st.startswith("✓") else "bad" if st.startswith("✗") else "warn",))
|
|
644
|
+
|
|
645
|
+
def start(self):
|
|
646
|
+
abis = [a for a, v in self.abi.items() if v.get()]
|
|
647
|
+
if not Path(self.script.get()).is_file():
|
|
648
|
+
return messagebox.showerror("py2apk", "Önce bir .py dosyası seçin")
|
|
649
|
+
if not re.fullmatch(r"[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+", self.pkg.get()):
|
|
650
|
+
return messagebox.showerror("py2apk", "Geçersiz paket adı (örn. com.ben.uygulama)")
|
|
651
|
+
if not abis:
|
|
652
|
+
return messagebox.showerror("py2apk", "En az bir mimari seçin")
|
|
653
|
+
if not self.port.get().isdigit() or not (1024 <= int(self.port.get()) <= 65535):
|
|
654
|
+
return messagebox.showerror("py2apk", "Port 1024-65535 arasında olmalı")
|
|
655
|
+
cfg = dict(script=self.script.get(), name=self.name.get() or "App", package=self.pkg.get(),
|
|
656
|
+
icon=self.icon.get() or None, version=self.version.get() or "1.0", vcode=1, abis=abis,
|
|
657
|
+
libs=self.libs(), repo=self.repo.get(), mode=self.mode.get(), port=int(self.port.get()))
|
|
658
|
+
self.btn.state(["disabled"]); self.pb.start(12); self.status.set("Başladı…")
|
|
659
|
+
|
|
660
|
+
def work():
|
|
661
|
+
try:
|
|
662
|
+
self.q.put(("done", str(build_apk(cfg))))
|
|
663
|
+
except Fail as e:
|
|
664
|
+
self.q.put(("err", str(e)))
|
|
665
|
+
except Exception:
|
|
666
|
+
self.q.put(("err", traceback.format_exc()))
|
|
667
|
+
threading.Thread(target=work, daemon=True).start()
|
|
668
|
+
|
|
669
|
+
def pump(self):
|
|
670
|
+
try:
|
|
671
|
+
while True:
|
|
672
|
+
kind, val = self.q.get_nowait()
|
|
673
|
+
if kind == "log":
|
|
674
|
+
tag = ("cmd" if val.startswith("$ ") else "warn" if val.startswith("UYARI")
|
|
675
|
+
else "good" if val.startswith("HAZIR") else "")
|
|
676
|
+
self.logw.configure(state="normal"); self.logw.insert("end", val + "\n", tag)
|
|
677
|
+
self.logw.see("end"); self.logw.configure(state="disabled")
|
|
678
|
+
if val.strip():
|
|
679
|
+
self.status.set(val.strip()[:100])
|
|
680
|
+
elif kind == "refresh":
|
|
681
|
+
self.refresh()
|
|
682
|
+
else:
|
|
683
|
+
self.pb.stop(); self.btn.state(["!disabled"])
|
|
684
|
+
if kind == "err":
|
|
685
|
+
self.status.set("Hata"); messagebox.showerror("py2apk - hata", val[-1500:])
|
|
686
|
+
else:
|
|
687
|
+
self.status.set("Tamamlandı ✓")
|
|
688
|
+
if messagebox.askyesno("py2apk", f"APK hazır:\n{val}\n\nKlasörü açayım mı?"):
|
|
689
|
+
open_folder(Path(val).parent)
|
|
690
|
+
except queue.Empty:
|
|
691
|
+
pass
|
|
692
|
+
self.after(100, self.pump)
|
|
693
|
+
|
|
694
|
+
App().mainloop()
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
if __name__ == "__main__":
|
|
698
|
+
main()
|