ren-ui-python 0.1.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.
ren_ui/native/icon.cpp ADDED
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Build-time PNG -> native 16/32-pixel app icons; no Python imaging dependency.
3
+ #include <Application.h>
4
+ #include <AppFileInfo.h>
5
+ #include <Bitmap.h>
6
+ #include <File.h>
7
+ #include <IconUtils.h>
8
+ #include <TranslationUtils.h>
9
+ #include <View.h>
10
+ #include <algorithm>
11
+ #include <cmath>
12
+ #include <cstdio>
13
+ #include <cstring>
14
+ #include <memory>
15
+ #include <stdexcept>
16
+ #include <string>
17
+
18
+ void check(status_t status, const char* operation)
19
+ {
20
+ if (status != B_OK)
21
+ throw std::runtime_error(std::string(operation) + ": " + strerror(status));
22
+ }
23
+
24
+ void setIcon(BAppFileInfo& info, BBitmap* source, icon_size size)
25
+ {
26
+ BRect bounds(0, 0, size - 1, size - 1);
27
+ BBitmap rgba(bounds, B_BITMAP_ACCEPTS_VIEWS, B_RGBA32);
28
+ BBitmap indexed(bounds, B_CMAP8);
29
+ check(rgba.InitCheck(), "Create RGBA icon");
30
+ check(indexed.InitCheck(), "Create indexed icon");
31
+ memset(rgba.Bits(), 0, rgba.BitsLength());
32
+ auto* view = new BView(bounds, "icon", B_FOLLOW_NONE, 0);
33
+ rgba.AddChild(view);
34
+ if (!rgba.Lock()) throw std::runtime_error("Cannot lock icon bitmap");
35
+ float width = source->Bounds().Width() + 1;
36
+ float height = source->Bounds().Height() + 1;
37
+ float scale = std::min(float(size) / width, float(size) / height);
38
+ float w = std::max(1.f, std::floor(width * scale));
39
+ float h = std::max(1.f, std::floor(height * scale));
40
+ float x = std::floor((size - w) / 2), y = std::floor((size - h) / 2);
41
+ view->SetDrawingMode(B_OP_COPY);
42
+ view->SetHighColor(0, 0, 0, 0);
43
+ view->FillRect(bounds);
44
+ view->SetDrawingMode(B_OP_ALPHA);
45
+ view->SetBlendingMode(B_PIXEL_ALPHA, B_ALPHA_COMPOSITE);
46
+ view->DrawBitmap(source, source->Bounds(), BRect(x, y, x + w - 1, y + h - 1),
47
+ B_FILTER_BITMAP_BILINEAR);
48
+ view->Sync();
49
+ rgba.Unlock();
50
+ check(BIconUtils::ConvertToCMAP8(&rgba, &indexed), "Convert icon palette");
51
+ check(info.SetIcon(&indexed, size, false), "Write app icon");
52
+ }
53
+
54
+ int main(int argc, char** argv)
55
+ {
56
+ if (argc != 3) return 2;
57
+ try {
58
+ BApplication app("application/x-vnd.ren-icon-builder");
59
+ check(app.InitCheck(), "Connect to Haiku app_server");
60
+ std::unique_ptr<BBitmap> source(BTranslationUtils::GetBitmapFile(argv[1]));
61
+ if (!source) throw std::runtime_error("Cannot decode PNG; check Haiku PNG Translator");
62
+ check(source->InitCheck(), "Decode PNG");
63
+ if (source->Bounds().Width() >= 4096 || source->Bounds().Height() >= 4096
64
+ || source->BitsLength() > 64 * 1024 * 1024)
65
+ throw std::runtime_error("PNG icon exceeds 4096 pixels or 64 MiB");
66
+ BFile executable(argv[2], B_READ_WRITE);
67
+ check(executable.InitCheck(), "Open launcher");
68
+ BAppFileInfo info(&executable);
69
+ check(info.InitCheck(), "Open app metadata");
70
+ info.SetInfoLocation(B_USE_BOTH_LOCATIONS);
71
+ setIcon(info, source.get(), B_LARGE_ICON);
72
+ setIcon(info, source.get(), B_MINI_ICON);
73
+ puts("Generated PNG app icons: 32x32 and 16x16, resources and attributes");
74
+ return 0;
75
+ } catch (const std::exception& error) {
76
+ fprintf(stderr, "ren-icon: %s\n", error.what());
77
+ return 1;
78
+ }
79
+ }
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Compiled per application. Resolve the actual image, including Deskbar symlinks.
3
+ #include <OS.h>
4
+ #include <image.h>
5
+ #include <Path.h>
6
+ #include <cstdio>
7
+ #include <cstdlib>
8
+ #include <dlfcn.h>
9
+ #include <string>
10
+ #include <vector>
11
+ #include <unistd.h>
12
+
13
+ #ifndef REN_APP_NAME
14
+ #error REN_APP_NAME must be supplied by the build tool
15
+ #endif
16
+
17
+ int main(int argc, char** argv)
18
+ {
19
+ image_info image;
20
+ int32 cookie = 0;
21
+ std::string executable;
22
+ while (get_next_image_info(B_CURRENT_TEAM, &cookie, &image) == B_OK) {
23
+ if (image.type == B_APP_IMAGE) {
24
+ executable = image.name;
25
+ break;
26
+ }
27
+ }
28
+ BPath path(executable.c_str());
29
+ BPath apps;
30
+ BPath prefix;
31
+ if (executable.empty() || path.GetParent(&apps) != B_OK || apps.GetParent(&prefix) != B_OK) {
32
+ fprintf(stderr, "ren-ui: cannot locate application image\n");
33
+ return 1;
34
+ }
35
+ std::string root = std::string(prefix.Path()) + "/data/" REN_APP_NAME;
36
+ std::string pythonPath = root + "/runtime:" + root + "/app";
37
+ std::string entry = root + "/app/" REN_ENTRY;
38
+ if (setenv("PYTHONPATH", pythonPath.c_str(), 1) != 0
39
+ || setenv("REN_UI_ASSET_ROOT", (root + "/app").c_str(), 1) != 0 || chdir((root + "/app").c_str()) != 0) {
40
+ perror("ren-ui: application directory");
41
+ return 1;
42
+ }
43
+ std::vector<char*> arguments;
44
+ arguments.push_back(const_cast<char*>(REN_PYTHON));
45
+ arguments.push_back(const_cast<char*>("-s"));
46
+ arguments.push_back(const_cast<char*>("-B"));
47
+ arguments.push_back(const_cast<char*>(entry.c_str()));
48
+ for (int i = 1; i < argc; ++i) arguments.push_back(argv[i]);
49
+ arguments.push_back(nullptr);
50
+ // Keep this executable as the application's image so Tracker/BRoster see
51
+ // its own signature and resources, instead of the generic Python binary.
52
+ void* python = dlopen(REN_PYTHON_LIBRARY, RTLD_NOW | RTLD_GLOBAL);
53
+ if (!python) {
54
+ fprintf(stderr, "ren-ui: cannot load %s: %s\n", REN_PYTHON_LIBRARY, dlerror());
55
+ return 1;
56
+ }
57
+ using PythonMain = int (*)(int, char**);
58
+ auto run = reinterpret_cast<PythonMain>(dlsym(python, "Py_BytesMain"));
59
+ if (!run) {
60
+ fprintf(stderr, "ren-ui: Python runtime has no Py_BytesMain\n");
61
+ return 1;
62
+ }
63
+ return run(static_cast<int>(arguments.size()) - 1, arguments.data());
64
+ }
ren_ui/project.py ADDED
@@ -0,0 +1,209 @@
1
+ """Validated, explicit application manifests; no shell interpolation."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path, PurePosixPath
6
+ import re
7
+ import struct
8
+ import zlib
9
+ import tomllib
10
+
11
+
12
+ class ProjectError(ValueError):
13
+ pass
14
+
15
+
16
+ def _string(table, key, default=None):
17
+ value = table.get(key, default)
18
+ if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value):
19
+ raise ProjectError(f"{key} must be a nonempty, single-line string")
20
+ return value
21
+
22
+
23
+ def _relative(value):
24
+ if not isinstance(value, str) or not value or "\\" in value or any(ord(c) < 32 for c in value):
25
+ raise ProjectError("Source paths must be nonempty relative POSIX paths")
26
+ path = PurePosixPath(value)
27
+ if path.is_absolute() or ".." in path.parts or str(path) == ".":
28
+ raise ProjectError(f"Path must stay within the project: {value}")
29
+ return path
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Project:
34
+ root: Path
35
+ name: str
36
+ version: str
37
+ revision: int
38
+ title: str
39
+ signature: str
40
+ summary: str
41
+ description: str
42
+ author: str
43
+ license: str
44
+ license_file: str
45
+ entry: str
46
+ include: tuple[str, ...]
47
+ requires: tuple[str, ...]
48
+ icon: str | None = None
49
+
50
+ @property
51
+ def package_version(self):
52
+ return f"{self.version}-{self.revision}"
53
+
54
+ @classmethod
55
+ def load(cls, root: Path):
56
+ root = root.resolve()
57
+ try:
58
+ data = tomllib.loads((root / "ren.toml").read_text(encoding="utf-8"))
59
+ except (OSError, tomllib.TOMLDecodeError) as exc:
60
+ raise ProjectError(f"Cannot read {root / 'ren.toml'}: {exc}") from exc
61
+ unknown = set(data) - {"app", "build"}
62
+ if unknown:
63
+ raise ProjectError(f"Unknown manifest tables: {', '.join(sorted(unknown))}")
64
+ app, build = data.get("app", {}), data.get("build", {})
65
+ if not isinstance(app, dict) or not isinstance(build, dict):
66
+ raise ProjectError("app and build must be TOML tables")
67
+ unknown = set(app) - {"name", "version", "revision", "title", "signature", "summary", "description", "author", "license", "license-file", "entry", "icon"}
68
+ unknown |= set(build) - {"include", "requires"}
69
+ if unknown:
70
+ raise ProjectError(f"Unknown manifest keys: {', '.join(sorted(unknown))}")
71
+ name = _string(app, "name")
72
+ if not re.fullmatch(r"[a-z][a-z0-9_]*", name):
73
+ raise ProjectError("name must start with a lowercase letter and contain only a-z, 0-9, _")
74
+ version = _string(app, "version", "0.1.0")
75
+ if not re.fullmatch(r"[0-9]+(?:\.[0-9]+){0,3}", version):
76
+ raise ProjectError("version must contain 1–4 numeric components, e.g. 1.2.0")
77
+ revision = app.get("revision", 1)
78
+ if type(revision) is not int or revision < 1:
79
+ raise ProjectError("revision must be a positive integer")
80
+ signature = _string(app, "signature", f"application/x-vnd.ren-{name}")
81
+ if not re.fullmatch(r"application/[A-Za-z0-9][A-Za-z0-9.+_-]*", signature):
82
+ raise ProjectError("signature must be an application MIME type")
83
+ title = _string(app, "title", name)
84
+ if title in (".", "..") or "/" in title or "\\" in title:
85
+ raise ProjectError("title must be a single Deskbar filename without slashes")
86
+ license_name = _string(app, "license", "MIT")
87
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.+-]*", license_name):
88
+ raise ProjectError("license must be a license identifier such as MIT")
89
+ entry = str(_relative(_string(app, "entry", "main.py")))
90
+ if not entry.endswith(".py"):
91
+ raise ProjectError("entry must be a Python file")
92
+ license_file = str(_relative(_string(app, "license-file", "LICENSE")))
93
+ include = build.get("include", [entry])
94
+ requires = build.get("requires", [])
95
+ if not isinstance(include, list) or not include:
96
+ raise ProjectError("build.include must be a nonempty array of source paths")
97
+ include = tuple(str(_relative(path)) for path in include)
98
+ if not isinstance(requires, list) or not all(isinstance(item, str) and re.fullmatch(
99
+ r"[a-zA-Z0-9_:+.\-]+(?:\s+(?:>=|<=|==|!=|>|<|=)\s+[a-zA-Z0-9_.+~\-]+)?", item
100
+ ) for item in requires):
101
+ raise ProjectError("build.requires must contain Haiku dependency expressions")
102
+ project = cls(root, name, version, revision, title, signature,
103
+ _string(app, "summary", f"{name} application"),
104
+ _string(app, "description", app.get("summary", f"{name} application")),
105
+ _string(app, "author", "Unknown author"), license_name, license_file,
106
+ entry, include, tuple(requires),
107
+ str(_relative(_string(app, "icon"))) if "icon" in app else None)
108
+ if project.icon is not None:
109
+ project.icon_bytes()
110
+ files = project.source_files()
111
+ if entry not in files:
112
+ raise ProjectError("entry must be included by build.include")
113
+ if not project.safe_path(license_file).is_file():
114
+ raise ProjectError(f"License file is missing: {license_file}")
115
+ return project
116
+
117
+ def icon_bytes(self) -> bytes:
118
+ if self.icon is None:
119
+ return b""
120
+ path = self.safe_path(self.icon)
121
+ suffix = path.suffix.lower()
122
+ if suffix not in {".hvif", ".png"} or not path.is_file():
123
+ raise ProjectError("app.icon must name an existing .hvif or .png file")
124
+ limit = 64 * 1024 if suffix == ".hvif" else 16 * 1024 * 1024
125
+ with path.open("rb") as file:
126
+ data = file.read(limit + 1)
127
+ if suffix == ".hvif":
128
+ if not 7 <= len(data) <= limit or not data.startswith(b"ncif"):
129
+ raise ProjectError("app.icon must be an HVIF file of at most 64 KiB")
130
+ else:
131
+ if (len(data) < 33 or len(data) > limit
132
+ or data[:8] != b"\x89PNG\r\n\x1a\n"
133
+ or data[8:16] != b"\x00\x00\x00\rIHDR"
134
+ or zlib.crc32(data[12:29]) != struct.unpack(">I", data[29:33])[0]):
135
+ raise ProjectError("app.icon must be a PNG file with a valid IHDR, at most 16 MiB")
136
+ width, height = struct.unpack(">II", data[16:24])
137
+ if not 1 <= width <= 4096 or not 1 <= height <= 4096:
138
+ raise ProjectError("PNG app.icon dimensions must be between 1 and 4096")
139
+ offset, pixels, ended = 8, False, False
140
+ while offset < len(data):
141
+ if offset + 12 > len(data):
142
+ raise ProjectError("PNG app.icon has a truncated chunk")
143
+ length = struct.unpack(">I", data[offset:offset + 4])[0]
144
+ end = offset + length + 12
145
+ kind = data[offset + 4:offset + 8]
146
+ if end > len(data) or zlib.crc32(data[offset + 4:end - 4]) != struct.unpack(">I", data[end - 4:end])[0]:
147
+ raise ProjectError("PNG app.icon has a truncated chunk or invalid CRC")
148
+ if kind == b"IHDR" and offset != 8:
149
+ raise ProjectError("PNG app.icon has duplicate IHDR chunks")
150
+ pixels |= kind == b"IDAT" and length > 0
151
+ offset = end
152
+ if kind == b"IEND":
153
+ ended = length == 0 and offset == len(data)
154
+ break
155
+ if not pixels or not ended:
156
+ raise ProjectError("PNG app.icon needs image data and a final IEND chunk")
157
+
158
+ return data
159
+
160
+ def safe_path(self, relative: str) -> Path:
161
+ relative = _relative(relative)
162
+ current = self.root
163
+ for part in relative.parts:
164
+ current /= part
165
+ if current.is_symlink():
166
+ raise ProjectError(f"Symlinked sources are not supported: {relative}")
167
+ if not current.resolve().is_relative_to(self.root):
168
+ raise ProjectError(f"Source escapes project: {relative}")
169
+ return current
170
+
171
+ def source_files(self) -> dict[str, Path]:
172
+ files = {}
173
+ excluded = {".git", ".venv", "__pycache__", ".ren-build", "dist", "build"}
174
+ def collect(relative):
175
+ path = self.safe_path(relative)
176
+ if path.is_dir():
177
+ for child in sorted(path.iterdir()):
178
+ if child.name not in excluded and not child.name.endswith((".pyc", ".pyo")):
179
+ collect(child.relative_to(self.root).as_posix())
180
+ elif path.is_file():
181
+ files[relative] = path
182
+ else:
183
+ raise ProjectError(f"Source is missing or not a regular file: {relative}")
184
+ for relative in self.include:
185
+ collect(relative)
186
+ if self.icon is not None:
187
+ collect(self.icon)
188
+ return files
189
+
190
+
191
+ def quote(value: str) -> str:
192
+ """Haiku PackageInfo and RDEF quoted string (not shell quoting)."""
193
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
194
+
195
+
196
+ def package_info(project: Project, architecture: str, python_package: str, secondary: str = "") -> str:
197
+ suffix = f"_{secondary}" if secondary else ""
198
+ fields = {
199
+ "name": project.name, "version": project.package_version,
200
+ "architecture": architecture, "summary": quote(project.summary),
201
+ "description": quote(project.description), "packager": quote(project.author),
202
+ "vendor": quote(project.author), "copyrights": quote(project.author),
203
+ "licenses": quote(f"{project.name}_{project.license}"),
204
+ }
205
+ lines = [f"{key} {value}" for key, value in fields.items()]
206
+ lines += ["provides {", f" {project.name} = {project.package_version}", "}", "requires {",
207
+ f" haiku{suffix}", f" lib:libstdc++{suffix}", f" lib:libgcc_s{suffix}", f" {python_package}"]
208
+ lines += [f" {item}" for item in project.requires]
209
+ return "\n".join(lines + ["}", ""])
ren_ui/py.typed ADDED
File without changes
ren_ui/resources.py ADDED
@@ -0,0 +1,30 @@
1
+ """Locate assets identically in a source project and an installed HPKG."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+ import sys
7
+
8
+
9
+ def asset_path(name: str | Path) -> Path:
10
+ """Resolve a project-relative resource; ren run and the launcher set the root."""
11
+ path = Path(name)
12
+ if path.is_absolute() or '..' in path.parts:
13
+ raise ValueError("Asset name must be relative to the application directory")
14
+ root = os.environ.get("REN_UI_ASSET_ROOT")
15
+ if root is None:
16
+ entry = getattr(sys.modules.get("__main__"), "__file__", None)
17
+ if not entry:
18
+ raise RuntimeError("Use ren run or set REN_UI_ASSET_ROOT to the project directory")
19
+ root = str(Path(entry).resolve().parent)
20
+ return Path(root).resolve() / path
21
+
22
+
23
+ def image_path(source: str | Path) -> Path:
24
+ if not isinstance(source, (str, Path)) or not str(source) or '\0' in str(source):
25
+ raise ValueError("Image source must be a nonempty file path without NUL characters")
26
+ path = Path(source)
27
+ path = path if path.is_absolute() else asset_path(path)
28
+ if not path.is_file():
29
+ raise ValueError(f"Image file not found: {path}")
30
+ return path.resolve()
ren_ui/system.py ADDED
@@ -0,0 +1,70 @@
1
+ """Selected Application Kit and Storage Kit services (require an active app)."""
2
+ import os
3
+ from pathlib import Path
4
+
5
+ from .app import Application
6
+ from ._native import utf8
7
+
8
+
9
+ def _native():
10
+ app = Application._current
11
+ if app is None or app._closed:
12
+ raise RuntimeError("Show a window before using Haiku services")
13
+ app._check_ui_thread()
14
+ return app._native
15
+
16
+
17
+ def alert(text: str, *, title="Message"):
18
+ _native().call("alert", utf8(title), utf8(text))
19
+
20
+
21
+ def confirm(text: str, *, title="Confirm", accept="OK", cancel="Cancel") -> bool:
22
+ """Show a native two-button dialog; Escape/cancel returns False."""
23
+ return _native().call("confirm", utf8(title), utf8(text), utf8(accept), utf8(cancel)) == 1
24
+
25
+
26
+ def _file_panel(save: bool, title: str | None, directory: str | Path | None, name: str = "") -> Path | None:
27
+ start = ""
28
+ if directory is not None:
29
+ if not Path(directory).is_dir():
30
+ raise ValueError("directory must be an existing directory")
31
+ start = str(Path(directory).resolve())
32
+ if "/" in name:
33
+ raise ValueError("name must be a file name without a directory")
34
+ path = _native().call("file_panel", int(save), utf8(title or ""), utf8(start), utf8(name))
35
+ return Path(os.fsdecode(path)) if path else None
36
+
37
+
38
+ def open_file(*, title: str | None = None, directory: str | Path | None = None) -> Path | None:
39
+ """Let the user pick an existing file in a native panel; None if cancelled."""
40
+ return _file_panel(False, title, directory)
41
+
42
+
43
+ def save_file(*, title: str | None = None, directory: str | Path | None = None, name: str = "") -> Path | None:
44
+ """Let the user choose where to save; None if cancelled. Writes nothing."""
45
+ return _file_panel(True, title, directory, name)
46
+
47
+
48
+ def set_clipboard(text: str):
49
+ _native().call("clipboard_set", utf8(text))
50
+
51
+
52
+ def get_clipboard() -> str:
53
+ return _native().call("clipboard_get").decode("utf-8")
54
+
55
+
56
+ def open_url(url: str):
57
+ """Ask BRoster to open an http, https or mailto URL."""
58
+ if url.split(":", 1)[0].lower() not in ("http", "https", "mailto"):
59
+ raise ValueError("Supported URL schemes: http, https, mailto")
60
+ _native().call("launch", utf8(url))
61
+
62
+
63
+ def settings_directory(app_name: str, *, create=True) -> Path:
64
+ if not app_name or app_name in (".", "..") or "/" in app_name or "\\" in app_name:
65
+ raise ValueError("app_name must be a single directory name")
66
+ utf8(app_name)
67
+ directory = Path(_native().call("directory", 0).decode("utf-8")) / app_name
68
+ if create:
69
+ directory.mkdir(parents=True, exist_ok=True)
70
+ return directory