pythonnative 0.24.0__py3-none-any.whl → 0.26.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.
Files changed (52) hide show
  1. pythonnative/__init__.py +16 -4
  2. pythonnative/animated.py +2 -2
  3. pythonnative/cli/pn.py +172 -12
  4. pythonnative/components.py +59 -0
  5. pythonnative/diagnostics.py +27 -0
  6. pythonnative/hooks.py +364 -81
  7. pythonnative/native_modules/app_state.py +2 -1
  8. pythonnative/native_modules/battery.py +3 -2
  9. pythonnative/native_modules/biometrics.py +2 -1
  10. pythonnative/native_modules/camera.py +16 -7
  11. pythonnative/native_modules/clipboard.py +3 -2
  12. pythonnative/native_modules/file_system.py +3 -2
  13. pythonnative/native_modules/haptics.py +7 -6
  14. pythonnative/native_modules/linking.py +62 -6
  15. pythonnative/native_modules/location.py +11 -8
  16. pythonnative/native_modules/net_info.py +2 -1
  17. pythonnative/native_modules/notifications.py +132 -9
  18. pythonnative/native_modules/permissions.py +27 -3
  19. pythonnative/native_modules/share.py +2 -1
  20. pythonnative/native_views/android.py +149 -124
  21. pythonnative/native_views/desktop.py +50 -49
  22. pythonnative/native_views/ios.py +240 -161
  23. pythonnative/project/android.py +15 -3
  24. pythonnative/project/builder.py +172 -70
  25. pythonnative/project/config.py +28 -6
  26. pythonnative/project/devices.py +291 -0
  27. pythonnative/project/doctor.py +5 -4
  28. pythonnative/project/ios.py +51 -77
  29. pythonnative/project/permissions.py +19 -1
  30. pythonnative/project/runtime_assets.py +78 -166
  31. pythonnative/reconciler.py +518 -37
  32. pythonnative/runtime.py +407 -215
  33. pythonnative/screen.py +53 -7
  34. pythonnative/storage.py +2 -2
  35. pythonnative/suspense.py +417 -0
  36. pythonnative/templates/android_template/app/build.gradle +5 -0
  37. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +1 -0
  38. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +88 -12
  39. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +41 -17
  40. pythonnative/templates/ios_template/ios_template/BridgingHeader.h +14 -0
  41. pythonnative/templates/ios_template/ios_template/Info.plist +0 -2
  42. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +397 -0
  43. pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +31 -20
  44. pythonnative/templates/ios_template/ios_template/ViewController.swift +77 -198
  45. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +72 -47
  46. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/METADATA +2 -1
  47. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/RECORD +51 -48
  48. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/WHEEL +1 -1
  49. pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
  50. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/entry_points.txt +0 -0
  51. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/licenses/LICENSE +0 -0
  52. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/top_level.txt +0 -0
@@ -255,7 +255,7 @@ def configure_strings(project_dir: Path, config: AppConfig) -> None:
255
255
 
256
256
 
257
257
  def configure_manifest(project_dir: Path, config: AppConfig) -> None:
258
- """Inject ``<uses-permission>`` entries and the launch orientation.
258
+ """Inject permissions, the launch orientation, and deep-link filters.
259
259
 
260
260
  Args:
261
261
  project_dir: The staged Android project root.
@@ -272,11 +272,23 @@ def configure_manifest(project_dir: Path, config: AppConfig) -> None:
272
272
  orientation_attr = _ANDROID_ORIENTATION.get(config.orientation)
273
273
  if orientation_attr:
274
274
  content = content.replace(
275
- ' android:exported="true">',
276
- f' android:exported="true"\n android:screenOrientation="{orientation_attr}">',
275
+ ' android:exported="true"',
276
+ f' android:exported="true"\n android:screenOrientation="{orientation_attr}"',
277
277
  1,
278
278
  )
279
279
 
280
+ if config.url_schemes:
281
+ schemes = "".join(f' <data android:scheme="{scheme}" />\n' for scheme in config.url_schemes)
282
+ deep_link_filter = (
283
+ " <intent-filter>\n"
284
+ ' <action android:name="android.intent.action.VIEW" />\n'
285
+ ' <category android:name="android.intent.category.DEFAULT" />\n'
286
+ ' <category android:name="android.intent.category.BROWSABLE" />\n'
287
+ f"{schemes}"
288
+ " </intent-filter>\n"
289
+ )
290
+ content = content.replace(" </activity>", f"{deep_link_filter} </activity>", 1)
291
+
280
292
  manifest_path.write_text(content, encoding="utf-8")
281
293
 
282
294
 
@@ -2,9 +2,14 @@
2
2
 
3
3
  The [`Builder`][pythonnative.project.builder.Builder] ties the pieces
4
4
  together: it stages the bundled native template, runs the platform
5
- [`configurators`][pythonnative.project.android], invokes the native
6
- toolchains (Gradle / Xcode), and, on iOS, embeds the CPython runtime into
7
- the built app.
5
+ [`configurators`][pythonnative.project.android], stages the Python
6
+ sources and (on iOS) the embedded CPython runtime into the native
7
+ project, and invokes the native toolchains (Gradle / Xcode).
8
+
9
+ On iOS, the staged project is self-contained: ``Python.xcframework`` is
10
+ linked at build time and an Xcode build phase installs the standard
11
+ library and app code into the bundle, so ``xcodebuild`` output is
12
+ directly runnable and archivable with no post-build patching.
8
13
 
9
14
  All shell-outs go through a small
10
15
  [`CommandRunner`][pythonnative.project.builder.CommandRunner] abstraction
@@ -20,7 +25,9 @@ builder stops at producing installable/archivable artifacts.
20
25
 
21
26
  from __future__ import annotations
22
27
 
28
+ import compileall
23
29
  import os
30
+ import platform as platform_module
24
31
  import subprocess
25
32
  import sys
26
33
  from dataclasses import dataclass, field
@@ -251,11 +258,15 @@ class Builder:
251
258
 
252
259
  # -- Preparation ----------------------------------------------------
253
260
 
254
- def prepare(self, platform: str) -> PreparedProject:
261
+ def prepare(self, platform: str, *, release: bool = False) -> PreparedProject:
255
262
  """Stage and configure the native project for ``platform``.
256
263
 
257
264
  Args:
258
265
  platform: ``"android"`` or ``"ios"``.
266
+ release: Prepare for a release/store build. On iOS this
267
+ byte-compiles the staged Python code and drops the
268
+ ``.py`` sources from the bundle. (Chaquopy does the
269
+ equivalent on Android automatically.)
259
270
 
260
271
  Returns:
261
272
  A [`PreparedProject`][pythonnative.project.builder.PreparedProject].
@@ -286,9 +297,8 @@ class Builder:
286
297
  )
287
298
 
288
299
  ios_layout = ios_config.configure(project_dir, self.config, log=self.log)
289
- # iOS Python sources are staged into a side directory and embedded
290
- # into the built .app after the build.
291
- self._stage_ios_python(build_dir)
300
+ self._stage_ios_python(project_dir, release=release)
301
+ self._link_ios_runtime(project_dir)
292
302
  return PreparedProject(
293
303
  platform=platform,
294
304
  build_dir=build_dir,
@@ -297,25 +307,101 @@ class Builder:
297
307
  ios=ios_layout,
298
308
  )
299
309
 
300
- def _stage_ios_python(self, build_dir: Path) -> Path:
310
+ def _stage_ios_python(self, project_dir: Path, *, release: bool) -> None:
311
+ """Stage ``app/`` and ``app_packages/`` at the Xcode project root.
312
+
313
+ The Xcode project references both folders as bundle resources
314
+ and the "Install Python runtime" build phase converts any
315
+ binary modules inside them into signed frameworks.
316
+ """
301
317
  import shutil
302
318
 
303
- python_dir = build_dir / "python"
304
- if python_dir.exists():
305
- shutil.rmtree(python_dir)
306
- python_dir.mkdir(parents=True, exist_ok=True)
319
+ app_dir = project_dir / "app"
320
+ packages_dir = project_dir / "app_packages"
321
+ for directory in (app_dir, packages_dir):
322
+ if directory.exists():
323
+ shutil.rmtree(directory)
324
+ directory.mkdir(parents=True, exist_ok=True)
307
325
 
308
326
  app_src = self.config.project_root / "app"
309
- if app_src.is_dir():
310
- shutil.copytree(app_src, python_dir / "app", dirs_exist_ok=True)
327
+ if not app_src.is_dir():
328
+ raise BuildError(f"No app/ directory found at {app_src}; nothing to bundle.")
329
+ shutil.copytree(app_src, app_dir, dirs_exist_ok=True)
330
+
311
331
  if self.dev_lib_root.is_dir():
312
332
  shutil.copytree(
313
333
  self.dev_lib_root,
314
- python_dir / "pythonnative",
334
+ packages_dir / "pythonnative",
315
335
  dirs_exist_ok=True,
316
336
  ignore=android_config.LIB_IGNORE,
317
337
  )
318
- return python_dir
338
+
339
+ # rubicon-objc supplies the iOS Objective-C bridge; user
340
+ # requirements ride along in the same site directory.
341
+ result = self.runner.run(
342
+ [sys.executable, "-m", "pip", "install", "--no-deps", "--upgrade", "rubicon-objc", "-t", str(packages_dir)],
343
+ capture=True,
344
+ )
345
+ if not result.ok:
346
+ raise BuildError(f"pip install rubicon-objc failed:\n{result.stderr}")
347
+ if self.config.requirements:
348
+ result = self.runner.run(
349
+ [sys.executable, "-m", "pip", "install", "-t", str(packages_dir), *self.config.requirements],
350
+ capture=True,
351
+ )
352
+ if not result.ok:
353
+ raise BuildError(f"pip install of [requirements].packages failed:\n{result.stderr}")
354
+
355
+ if release:
356
+ self._compile_ios_bytecode(app_dir, packages_dir)
357
+
358
+ def _compile_ios_bytecode(self, *roots: Path) -> None:
359
+ """Byte-compile staged Python code and drop the ``.py`` sources.
360
+
361
+ Ships ``.pyc`` only: smaller bundles, faster cold start, and no
362
+ plain-text source in the store artifact. Requires the host
363
+ interpreter to match ``app.python_version``, since bytecode is
364
+ version-specific.
365
+ """
366
+ host_version = f"{sys.version_info[0]}.{sys.version_info[1]}"
367
+ if host_version != self.config.python_version:
368
+ self.log(
369
+ f"Skipping bytecode compilation: the build host runs Python {host_version} "
370
+ f"but app.python_version is {self.config.python_version}, and bytecode is "
371
+ "version-specific. The app will ship .py sources. Run pn with Python "
372
+ f"{self.config.python_version} to ship bytecode."
373
+ )
374
+ return
375
+ for root in roots:
376
+ # legacy=True writes module.pyc next to module.py (no
377
+ # __pycache__), which the embedded interpreter imports
378
+ # directly once the .py files are gone.
379
+ if not compileall.compile_dir(str(root), quiet=1, legacy=True, optimize=1):
380
+ raise BuildError(f"Byte-compiling {root} failed; fix the syntax error above.")
381
+ for source in root.rglob("*.py"):
382
+ source.unlink()
383
+ self.log("Compiled Python sources to bytecode for release.")
384
+
385
+ def _link_ios_runtime(self, project_dir: Path) -> None:
386
+ """Make ``Python.xcframework`` available at the project root.
387
+
388
+ A symlink into the shared runtime cache is preferred (the
389
+ framework is hundreds of megabytes); staging falls back to a
390
+ copy when symlinks are unavailable.
391
+ """
392
+ import shutil
393
+
394
+ runtime = self._ios_runtime()
395
+ link = project_dir / "Python.xcframework"
396
+ if link.is_symlink() or link.is_file():
397
+ link.unlink()
398
+ elif link.is_dir():
399
+ shutil.rmtree(link)
400
+ try:
401
+ link.symlink_to(runtime.xcframework_dir, target_is_directory=True)
402
+ except OSError:
403
+ shutil.copytree(runtime.xcframework_dir, link)
404
+ self.log(f"Linked Python.xcframework (Python {self.config.python_version}).")
319
405
 
320
406
  # -- Android builds -------------------------------------------------
321
407
 
@@ -378,21 +464,28 @@ class Builder:
378
464
  # -- iOS builds -----------------------------------------------------
379
465
 
380
466
  def build_ios_simulator(self, prepared: PreparedProject) -> Path:
381
- """Build the iOS app for the simulator and embed the runtime.
467
+ """Build the iOS app for the simulator.
468
+
469
+ The Xcode build produces a complete, runnable ``.app``: the
470
+ Python framework is linked and the "Install Python runtime"
471
+ build phase embeds the standard library and staged sources.
382
472
 
383
473
  Args:
384
474
  prepared: A prepared iOS project.
385
475
 
386
476
  Returns:
387
- Path to the built ``.app`` with the runtime embedded.
477
+ Path to the built ``.app``.
388
478
 
389
479
  Raises:
390
- BuildError: If the runtime can't be prepared or the build
391
- fails.
480
+ BuildError: If the build fails.
392
481
  """
393
- runtime = self._ios_runtime()
394
482
  derived = prepared.project_dir / "build"
395
483
  settings = ios_config.build_settings(self.config)
484
+ # The simulator runs on the host CPU, and BeeWare's runtime
485
+ # install script requires a single-architecture build (its
486
+ # stdlib layout is per-arch), so pin ARCHS to the host.
487
+ host_arch = "arm64" if platform_module.machine() == "arm64" else "x86_64"
488
+ settings.append(f"ARCHS={host_arch}")
396
489
  result = self.runner.run(
397
490
  [
398
491
  "xcodebuild",
@@ -417,35 +510,72 @@ class Builder:
417
510
  app_path = derived / "Build" / "Products" / "Debug-iphonesimulator" / ios_config.APP_BUNDLE_NAME
418
511
  if not app_path.is_dir():
419
512
  raise BuildError(f"Built app not found at {app_path}.")
420
-
421
- site_packages = self._install_ios_site_packages(prepared.build_dir)
422
- ios_config.embed_runtime(
423
- app_path,
424
- runtime=runtime,
425
- destination="simulator",
426
- python_sources=prepared.build_dir / "python",
427
- site_packages=site_packages,
428
- log=self.log,
429
- )
430
513
  return app_path
431
514
 
432
- def build_ios_archive(self, prepared: PreparedProject) -> BuildArtifacts:
433
- """Archive the iOS app for a device and export a signed IPA.
515
+ def build_ios_device(self, prepared: PreparedProject) -> Path:
516
+ """Build a signed Debug app for a physical iOS device.
434
517
 
435
- This path is experimental: it embeds the device CPython slice into
436
- the archive before export and relies on ``xcodebuild`` to re-sign
437
- the embedded framework.
518
+ Requires ``[ios].development_team`` for automatic signing.
438
519
 
439
520
  Args:
440
521
  prepared: A prepared iOS project.
441
522
 
523
+ Returns:
524
+ Path to the built ``.app``.
525
+
526
+ Raises:
527
+ BuildError: If the build fails or signing is not set up.
528
+ """
529
+ if not self.config.ios.development_team:
530
+ raise BuildError(
531
+ "Running on a physical iOS device requires code signing. "
532
+ "Set [ios].development_team in pythonnative.toml."
533
+ )
534
+ derived = prepared.project_dir / "build"
535
+ settings = ios_config.build_settings(self.config)
536
+ settings.append("-allowProvisioningUpdates")
537
+ result = self.runner.run(
538
+ [
539
+ "xcodebuild",
540
+ "-project",
541
+ ios_config.PROJECT_FILE,
542
+ "-scheme",
543
+ ios_config.PROJECT_NAME,
544
+ "-configuration",
545
+ "Debug",
546
+ "-destination",
547
+ "generic/platform=iOS",
548
+ "-derivedDataPath",
549
+ str(derived),
550
+ "build",
551
+ *settings,
552
+ ],
553
+ cwd=prepared.project_dir,
554
+ )
555
+ if not result.ok:
556
+ raise BuildError("xcodebuild (device) failed. See output above.")
557
+
558
+ app_path = derived / "Build" / "Products" / "Debug-iphoneos" / ios_config.APP_BUNDLE_NAME
559
+ if not app_path.is_dir():
560
+ raise BuildError(f"Built app not found at {app_path}.")
561
+ return app_path
562
+
563
+ def build_ios_archive(self, prepared: PreparedProject, *, upload: bool = False) -> BuildArtifacts:
564
+ """Archive the iOS app and export a signed IPA (or upload it).
565
+
566
+ Args:
567
+ prepared: A prepared iOS project (use ``release=True``).
568
+ upload: Upload directly to App Store Connect instead of
569
+ exporting a local ``.ipa`` (requires
570
+ ``export_method = "app-store"`` and stored ASC
571
+ credentials in Xcode).
572
+
442
573
  Returns:
443
574
  The produced ``.ipa`` (and ``.xcarchive``) paths.
444
575
 
445
576
  Raises:
446
577
  BuildError: If archiving or export fails.
447
578
  """
448
- runtime = self._ios_runtime()
449
579
  archive_path = prepared.build_dir / "ios_template.xcarchive"
450
580
  settings = ios_config.build_settings(self.config, for_archive=True)
451
581
  result = self.runner.run(
@@ -461,6 +591,7 @@ class Builder:
461
591
  "generic/platform=iOS",
462
592
  "-archivePath",
463
593
  str(archive_path),
594
+ "-allowProvisioningUpdates",
464
595
  "archive",
465
596
  *settings,
466
597
  ],
@@ -469,20 +600,10 @@ class Builder:
469
600
  if not result.ok:
470
601
  raise BuildError("xcodebuild archive failed. See output above.")
471
602
 
472
- app_in_archive = archive_path / "Products" / "Applications" / ios_config.APP_BUNDLE_NAME
473
- if app_in_archive.is_dir():
474
- site_packages = self._install_ios_site_packages(prepared.build_dir)
475
- ios_config.embed_runtime(
476
- app_in_archive,
477
- runtime=runtime,
478
- destination="device",
479
- python_sources=prepared.build_dir / "python",
480
- site_packages=site_packages,
481
- log=self.log,
482
- )
483
-
484
603
  export_dir = prepared.build_dir / "export"
485
- options = ios_config.write_export_options(self.config, prepared.build_dir / "exportOptions.plist")
604
+ options = ios_config.write_export_options(
605
+ self.config, prepared.build_dir / "exportOptions.plist", upload=upload
606
+ )
486
607
  result = self.runner.run(
487
608
  [
488
609
  "xcodebuild",
@@ -493,6 +614,7 @@ class Builder:
493
614
  str(options),
494
615
  "-exportPath",
495
616
  str(export_dir),
617
+ "-allowProvisioningUpdates",
496
618
  ],
497
619
  cwd=prepared.project_dir,
498
620
  )
@@ -508,26 +630,6 @@ class Builder:
508
630
  except RuntimeError as exc:
509
631
  raise BuildError(str(exc)) from exc
510
632
 
511
- def _install_ios_site_packages(self, build_dir: Path) -> Optional[Path]:
512
- import shutil
513
-
514
- site_dir = build_dir / "platform-site"
515
- if site_dir.exists():
516
- shutil.rmtree(site_dir)
517
- site_dir.mkdir(parents=True, exist_ok=True)
518
-
519
- # rubicon-objc supplies the iOS Objective-C bridge.
520
- self.runner.run(
521
- [sys.executable, "-m", "pip", "install", "--no-deps", "--upgrade", "rubicon-objc", "-t", str(site_dir)],
522
- capture=True,
523
- )
524
- if self.config.requirements:
525
- self.runner.run(
526
- [sys.executable, "-m", "pip", "install", "-t", str(site_dir), *self.config.requirements],
527
- capture=True,
528
- )
529
- return site_dir
530
-
531
633
 
532
634
  # ======================================================================
533
635
  # Helpers
@@ -21,6 +21,7 @@ build = 1 # integer build number
21
21
  python_version = "3.11" # embedded CPython version
22
22
  orientation = "portrait" # portrait | landscape | all
23
23
  entry_point = "app/main.py" # module whose `App` is mounted
24
+ url_schemes = ["myapp"] # custom deep-link URL schemes
24
25
 
25
26
  [permissions] # see pythonnative.project.permissions
26
27
  camera = "Scan receipts."
@@ -45,7 +46,7 @@ provisioning_profile = "My App Distribution"
45
46
  [android]
46
47
  min_sdk = 24
47
48
  target_sdk = 34
48
- abi_filters = ["arm64-v8a", "x86_64"]
49
+ abi_filters = ["arm64-v8a", "x86_64"] # the default; add armeabi-v7a/x86 if needed
49
50
 
50
51
  [android.signing]
51
52
  keystore = "release.keystore"
@@ -72,10 +73,12 @@ CONFIG_FILENAME = "pythonnative.toml"
72
73
  """The fixed config filename looked up at the project root."""
73
74
 
74
75
  SUPPORTED_PYTHON_VERSIONS = ("3.10", "3.11", "3.12")
75
- """CPython versions accepted in ``app.python_version``."""
76
+ """CPython versions accepted in ``app.python_version``.
76
77
 
77
- IOS_SUPPORTED_PYTHON_VERSION = "3.11"
78
- """The CPython version with a pinned, verified iOS support build."""
78
+ Every listed version has a pinned, checksum-verified iOS runtime (see
79
+ ``runtime_assets.PINNED_ASSETS``) and is supported by Chaquopy on
80
+ Android.
81
+ """
79
82
 
80
83
  VALID_ORIENTATIONS = ("portrait", "landscape", "all")
81
84
  """Accepted values for ``app.orientation``."""
@@ -93,6 +96,7 @@ _JAVA_KEYWORDS = frozenset("""
93
96
 
94
97
  _APP_ID_SEGMENT = re.compile(r"^[a-z][a-z0-9_]*$")
95
98
  _VERSION_RE = re.compile(r"^\d+(\.\d+){0,3}$")
99
+ _URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*$")
96
100
 
97
101
 
98
102
  class ConfigError(Exception):
@@ -193,7 +197,9 @@ class AndroidConfig:
193
197
  target_sdk: int = 34
194
198
  compile_sdk: int = 34
195
199
  application_id: Optional[str] = None
196
- abi_filters: List[str] = field(default_factory=lambda: ["armeabi-v7a", "arm64-v8a", "x86", "x86_64"])
200
+ # arm64 devices plus x86_64 emulators. 32-bit ABIs (armeabi-v7a, x86)
201
+ # roughly double the native payload and are opt-in.
202
+ abi_filters: List[str] = field(default_factory=lambda: ["arm64-v8a", "x86_64"])
197
203
  permissions: List[str] = field(default_factory=list)
198
204
  signing: AndroidSigning = field(default_factory=AndroidSigning)
199
205
 
@@ -222,6 +228,9 @@ class AppConfig:
222
228
  python_version: Embedded CPython version (``app.python_version``).
223
229
  orientation: ``"portrait"``, ``"landscape"``, or ``"all"``.
224
230
  entry_point: Path to the entry module (``app.entry_point``).
231
+ url_schemes: Custom deep-link URL schemes the app handles
232
+ (``app.url_schemes``); wired into ``CFBundleURLTypes`` on
233
+ iOS and a ``VIEW`` intent filter on Android.
225
234
  permissions: The declared capability map (``[permissions]``).
226
235
  icon: Optional source icon path (``[assets].icon``).
227
236
  splash: Optional splash image path (``[assets].splash``).
@@ -240,6 +249,7 @@ class AppConfig:
240
249
  python_version: str = "3.11"
241
250
  orientation: str = "portrait"
242
251
  entry_point: str = "app/main.py"
252
+ url_schemes: List[str] = field(default_factory=list)
243
253
  permissions: Dict[str, _permissions.PermissionValue] = field(default_factory=dict)
244
254
  icon: Optional[str] = None
245
255
  splash: Optional[str] = None
@@ -358,6 +368,7 @@ class AppConfig:
358
368
  python_version=_opt_str(app, "python_version") or "3.11",
359
369
  orientation=(_opt_str(app, "orientation") or "portrait").lower(),
360
370
  entry_point=_opt_str(app, "entry_point") or "app/main.py",
371
+ url_schemes=_opt_str_list(app, "url_schemes"),
361
372
  permissions=dict(_expect_table(data, "permissions", optional=True)),
362
373
  ios=_parse_ios(_expect_table(data, "ios", optional=True)),
363
374
  android=_parse_android(_expect_table(data, "android", optional=True)),
@@ -406,6 +417,13 @@ class AppConfig:
406
417
  if not self.entry_point.strip():
407
418
  raise ConfigError("app.entry_point must not be empty.")
408
419
 
420
+ for scheme in self.url_schemes:
421
+ if not _URL_SCHEME_RE.match(scheme):
422
+ raise ConfigError(
423
+ f"app.url_schemes entry {scheme!r} is invalid; schemes must start with a letter "
424
+ "and contain only letters, digits, '+', '-', and '.'."
425
+ )
426
+
409
427
  unknown = _permissions.unknown_capabilities(self.permissions.keys())
410
428
  if unknown:
411
429
  known = ", ".join(sorted(_permissions.CAPABILITIES))
@@ -593,6 +611,8 @@ build = 1
593
611
  python_version = "{python_version}"
594
612
  orientation = "portrait" # portrait | landscape | all
595
613
  entry_point = "app/main.py"
614
+ # Custom URL schemes for deep links (e.g. "{name}" opens {name}://...).
615
+ # url_schemes = ["{name}"]
596
616
 
597
617
  # Declare the device capabilities your app needs. A string becomes the
598
618
  # iOS permission prompt text; `true` uses a sensible default.
@@ -626,7 +646,9 @@ export_method = "development" # development | ad-hoc | app-store | enterprise
626
646
  [android]
627
647
  min_sdk = 24
628
648
  target_sdk = 34
629
- abi_filters = ["armeabi-v7a", "arm64-v8a", "x86", "x86_64"]
649
+ # arm64 devices + x86_64 emulators (the default). Add "armeabi-v7a" or
650
+ # "x86" only if you must support 32-bit hardware; each ABI adds ~30 MB.
651
+ # abi_filters = ["arm64-v8a", "x86_64"]
630
652
 
631
653
  [android.signing]
632
654
  # keystore = "release.keystore"