mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-17 20:01:23 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00c6863e54 | ||
|
|
4863035a05 | ||
|
|
a30162ea63 | ||
|
|
24eaa97fde | ||
|
|
767b152423 |
No files matched your search
@@ -38,6 +38,6 @@ body:
|
||||
id: logs
|
||||
attributes:
|
||||
label: Diagnostics
|
||||
description: Drag the FramePort-diag-*.zip here (max 25 MB)
|
||||
description: "Drag the FramePort-diag-*.zip here (max 25 MB). If the box won't take it, submit the report and attach the zip in a comment."
|
||||
validations:
|
||||
required: true
|
||||
@@ -352,6 +352,11 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
|
||||
`T.px(n)`, Material defaults through the theme's text_theme; never hardcode a bare pixel number in ui/.
|
||||
`scripts/ui_smoke.py --scale 1.5 --viewport 2560x1440` renders it. Flet draws a grey box for invalid layouts (e.g.
|
||||
an `expand` child in a `wrap=True` Row).
|
||||
- One GUI per data folder (`core/instance.py`, 2026-10-11): `<data>/gui.lock` (flock/msvcrt, pid inside); a second
|
||||
start asks the open window to come to the front (`gui.focus`, read by `_watch_links`) and exits; a lock held with no
|
||||
window (no `gui.alive`) and older than 30 s is a leftover FramePort process and is ended. `main()` ends the process
|
||||
(`os._exit`) once `ft.run` returns: background threads (re-analysis, session triage) used to keep a windowless
|
||||
process running. Session triage shows one pop-up for all games of a burst (`_announce_sessions`).
|
||||
- Stopping the GUI: `pkill -f` patterns match your own shell — use `pgrep -f "[b]in/frameport-gui|[f]let-desktop-light"`.
|
||||
|
||||
## Round-2 polish (2026-10-02)
|
||||
|
||||
@@ -5,6 +5,8 @@ notes: Needed the LAUNCHER category fix.
|
||||
details: Source APK is a community 'patch+savefix+90Hz' build. Shares Vader Immortal's Unreal 4 Oculus input (GitHub
|
||||
49) - thumbs follow the touches (frame.unreal_thumb_touch) and controller poses are located at the right time
|
||||
(pose_time_fix); added 2026-10-09 from Vader's headset results, not yet checked in this game.
|
||||
sync_guard added 2026-10-11 for a vrclient xrSyncActions crash on the loading splash after the intro (GitHub
|
||||
#188; the same runtime race as Myst).
|
||||
tested_version: '1.0'
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
@@ -14,10 +16,11 @@ frame:
|
||||
- frame.unreal_thumb_touch
|
||||
adapter:
|
||||
pose_time_fix: 1
|
||||
sync_guard: 1
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 4ce6e3563da131e288645f4b62a634ac98043e55bc42b7c0ad5f461207d5f53c
|
||||
updated: '2026-10-09'
|
||||
updated: '2026-10-11'
|
||||
source_hint: Robo Recall- Unplugged
|
||||
@@ -1,13 +1,12 @@
|
||||
package: com.owlchemylabs.jobsimulator
|
||||
title: Job Simulator
|
||||
status: works
|
||||
notes: 'Without Lepton''s Android window: with it, the hands froze after a few seconds (GitHub #193).'
|
||||
tested_version: 1.4.0.6515
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
frame_remove:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
known_good_sha256: 64638d7f93bb93cc61f422a43a08bb0d35b9bb4b463e90a42da7002147f03d1b
|
||||
@@ -16,4 +15,5 @@ verified:
|
||||
frame_build: '20260930.6234839'
|
||||
agent: 33
|
||||
issue: 11
|
||||
updated: '2026-10-11'
|
||||
source_hint: Job Simulator
|
||||
@@ -1,14 +1,13 @@
|
||||
package: com.owlchemylabs.vacationsimulator
|
||||
title: Vacation Simulator
|
||||
status: issues
|
||||
notes: Controllers aren't tracked (reported; not yet diagnosed).
|
||||
status: works
|
||||
notes: 'Without Lepton''s Android window: with it (or after clicking it), the hands stayed on the floor (GitHub
|
||||
#163).'
|
||||
tested_version: 1.3.0.40142
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
frame_remove:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
refresh_rate: 90.0
|
||||
@@ -20,4 +19,5 @@ verified:
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 163
|
||||
updated: '2026-10-11'
|
||||
source_hint: com.owlchemylabs.vacationsimulator
|
||||
+6
-6
@@ -77,7 +77,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Unity: skip OVROverlay layers (fades, splash screens)](#frame-unity_no_overlay_copy) | `frame.unity_no_overlay_copy` | Steam Frame patches | 2 |
|
||||
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 9 |
|
||||
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 17 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 58 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 56 |
|
||||
| [Unity: headset always counts as worn](#frame-unity_user_presence) | `frame.unity_user_presence` | Steam Frame patches | 1 |
|
||||
| [GL shim for Unreal (no multisampled render-to-texture)](#frame-unreal_gl_shim) | `frame.unreal_gl_shim` | Steam Frame patches | 1 |
|
||||
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 1 |
|
||||
@@ -146,7 +146,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Native video surfaces (Batman renderer)](#adapter-surface_native) | `adapter.surface_native` | Game settings | 1 |
|
||||
| [Swap eyes](#adapter-swap_eyes) | `adapter.swap_eyes` | Game settings | |
|
||||
| [Retry refused picture formats](#adapter-swapchain_fix) | `adapter.swapchain_fix` | Game settings | |
|
||||
| [Steady controller input after focus](#adapter-sync_guard) | `adapter.sync_guard` | Game settings | 2 |
|
||||
| [Steady controller input after focus](#adapter-sync_guard) | `adapter.sync_guard` | Game settings | 3 |
|
||||
| [Hide Vulkan foveation](#adapter-vk_hide_fdm) | `adapter.vk_hide_fdm` | Game settings | 1 |
|
||||
| [Stop objects popping in and out](#adapter-vk_query_slots) | `adapter.vk_query_slots` | Game settings | |
|
||||
| [Vulkan shader dump (diagnostics)](#adapter-vk_shader_dump) | `adapter.vk_shader_dump` | Game settings | |
|
||||
@@ -162,7 +162,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Hide Android's navigation bar](#device-hide_navbar) | `device.hide_navbar` | Files and settings on the Frame | |
|
||||
| [Extra Lepton settings](#device-lepton_env) | `device.lepton_env` | Files and settings on the Frame | 3 |
|
||||
| [Controllers as a gamepad (Steam Input)](#device-steam_gamepad) | `device.steam_gamepad` | Files and settings on the Frame | |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 58 |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 56 |
|
||||
| [Launch options (SteamVR or OpenXR)](#pcvr-launch_args) | `pcvr.launch_args` | PC VR (Proton and Revive) | 2 |
|
||||
| [Provide the Oculus runtime from Revive (Frame)](#pcvr-libovr_redirect) | `pcvr.libovr_redirect` | PC VR (Proton and Revive) | |
|
||||
| [No Unreal crash reporter](#pcvr-no_crash_reporter) | `pcvr.no_crash_reporter` | PC VR (Proton and Revive) | |
|
||||
@@ -908,7 +908,7 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
|
||||
- `frame.unity_text_input` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity app with text fields (…): they close at once on the Frame because it has no system keyboard.
|
||||
- **Catalog games (58):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Audica, BattleSisters, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad and 50 more.
|
||||
- **Catalog games (56):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Audica, BattleSisters, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad and 48 more.
|
||||
- **Source:** [patches/frame/unity_text_input.py](../src/frameport/patches/frame/unity_text_input.py)
|
||||
|
||||
> Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
|
||||
@@ -1905,7 +1905,7 @@ For games that crash right after you return to them (for example after the Steam
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Troubleshooting → "Steady controller input after focus" (advanced).
|
||||
- **Catalog games (2):** Batman: Arkham Shadow, Myst.
|
||||
- **Catalog games (3):** Batman: Arkham Shadow, Myst, Robo Recall.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `runtime-input-crash` (fatal): The Frame's runtime crashed in its controller code, usually right after the game got focus. sync_guard runs input syncs (xrSyncActions) one at a time and pauses them briefly after focus returns.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
@@ -2134,7 +2134,7 @@ Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard
|
||||
- `device.text_input_window`
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** App with text fields: key presses only reach a shown Android window.
|
||||
- **Catalog games (58):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Audica, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad, BodyCombat and 50 more.
|
||||
- **Catalog games (56):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Audica, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad, BodyCombat and 48 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as "Gamescope" (the Frame's compositor). Pairs with "Make Unity text fields work without a system keyboard".
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
|
||||
__version__ = "1.0.2"
|
||||
__version__ = "1.0.3"
|
||||
@@ -0,0 +1,139 @@
|
||||
"""One FramePort window at a time (no Flet).
|
||||
|
||||
Two GUI processes on one data folder fought over library.json and both re-analysed the library and triaged the same
|
||||
play sessions (the Library flickered under a stream of pop-ups). The GUI holds an exclusive lock on
|
||||
`<data>/gui.lock` for its lifetime (released by the OS when the process ends, also after a crash). A second start:
|
||||
- the window is there (fresh `gui.alive` heartbeat): asks it to come to the front (`gui.focus`) and exits;
|
||||
- the lock is held but no window answers (a process left over after its window went away): ends that process (only
|
||||
when it is a FramePort GUI) and takes over.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import user_data_dir
|
||||
|
||||
_held = None # the open lock file (kept for the process lifetime)
|
||||
|
||||
|
||||
def lock_file() -> Path:
|
||||
return user_data_dir() / "gui.lock"
|
||||
|
||||
|
||||
def focus_file() -> Path:
|
||||
return user_data_dir() / "gui.focus"
|
||||
|
||||
|
||||
def _try_lock(f) -> bool:
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
f.seek(0)
|
||||
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def acquire(path: Path | None = None) -> bool:
|
||||
"""Take the GUI lock; False when another process holds it."""
|
||||
global _held
|
||||
path = path or lock_file()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = open(path, "a+") # noqa: SIM115 (kept open on purpose)
|
||||
if not _try_lock(f):
|
||||
f.close()
|
||||
return False
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
f.write(str(os.getpid()))
|
||||
f.flush()
|
||||
_held = f
|
||||
return True
|
||||
|
||||
|
||||
def holder(path: Path | None = None) -> int | None:
|
||||
try:
|
||||
return int((path or lock_file()).read_text().strip() or 0) or None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _is_frameport_gui(pid: int) -> bool:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
cmd = " ".join(psutil.Process(pid).cmdline()).lower()
|
||||
except Exception: # noqa: BLE001 (gone, or not ours to inspect)
|
||||
return False
|
||||
return "frameport" in cmd
|
||||
|
||||
|
||||
STARTUP_GRACE = 30.0 # a GUI this young may still be opening its window (no heartbeat yet): never end it
|
||||
|
||||
|
||||
def end_leftover(pid: int, wait: float = 5.0) -> bool:
|
||||
"""End a FramePort GUI process that has no window any more; True when it's gone."""
|
||||
if pid == os.getpid() or not _is_frameport_gui(pid):
|
||||
return False
|
||||
import psutil
|
||||
|
||||
try:
|
||||
p = psutil.Process(pid)
|
||||
if time.time() - p.create_time() < STARTUP_GRACE:
|
||||
return False
|
||||
p.terminate()
|
||||
try:
|
||||
p.wait(wait)
|
||||
except psutil.TimeoutExpired:
|
||||
p.kill()
|
||||
p.wait(wait)
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def ask_focus() -> None:
|
||||
try:
|
||||
focus_file().touch()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def take_focus_request() -> bool:
|
||||
"""True once after another start asked the window to come to the front."""
|
||||
try:
|
||||
focus_file().unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def claim(window_open, retries: int = 20) -> bool:
|
||||
"""Called at GUI start: True = this process is the GUI; False = another window is open (it was asked to come to
|
||||
the front). `window_open()` says whether a window answers (urlhandler.app_running)."""
|
||||
if acquire():
|
||||
return True
|
||||
if window_open():
|
||||
ask_focus()
|
||||
return False
|
||||
pid = holder()
|
||||
if pid:
|
||||
end_leftover(pid)
|
||||
for _ in range(retries): # the OS releases the lock when the process is gone
|
||||
if acquire():
|
||||
return True
|
||||
time.sleep(0.25)
|
||||
if window_open():
|
||||
ask_focus()
|
||||
return False
|
||||
@@ -178,8 +178,9 @@ def _problem_fields(g: dict | None, description: str, env: dict, bundle_name: st
|
||||
a = g.get("analysis") or {}
|
||||
game = f"{title} — {g['package']} (version {a.get('version') or '?'}, {a.get('engine') or '?'} / " \
|
||||
f"{a.get('xr') or '?'})"
|
||||
logs = f"⬇ Drag and drop {bundle_name} (the diagnostics zip FramePort just saved) into this box." \
|
||||
if bundle_name else ""
|
||||
# some browsers don't let the prefilled box take a file (GitHub #196): the comment route always works
|
||||
logs = f"⬇ Drag and drop {bundle_name} (the diagnostics zip FramePort just saved) into this box. If it won't " \
|
||||
"take the file, submit the report and attach the zip in a comment." if bundle_name else ""
|
||||
return {
|
||||
# a game's report is about the game on the Frame, not a FramePort fault (titles read as a list of app bugs)
|
||||
"title": red.text(f"[Game report] {title}" if g else "[App] FramePort"),
|
||||
|
||||
@@ -538,6 +538,10 @@
|
||||
"FramePort isn't sure which program starts this game. Open the game and use … → Change program… to pick it.": "",
|
||||
"FramePort keeps its own copies; nothing is installed system-wide": "",
|
||||
"FramePort live view": "",
|
||||
"FramePort looked at your last session of {n} game and has something to try.": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"FramePort looked at your last session of {title} and has something to try.": "",
|
||||
"FramePort picked {value} to start this game, but isn't sure. Check it before installing.": "",
|
||||
"FramePort saves a diagnostics zip (no game files or personal data) and opens a GitHub issue. Drag the zip into it and submit.": "",
|
||||
@@ -990,6 +994,7 @@
|
||||
"Open in browser": "",
|
||||
"Open the Monitor": "",
|
||||
"Open {scheme}:// links with FramePort?": "",
|
||||
"Open {title}": "",
|
||||
"OpenGL ES games run through a translation to Vulkan that is stricter than Quest drivers. Vulkan games run directly.": "",
|
||||
"OpenGL ES shader dump (diagnostics)": "",
|
||||
"OpenGL ES shader fixes": "",
|
||||
|
||||
+46
-5
@@ -53,12 +53,16 @@ def _stop_link_watch() -> None:
|
||||
|
||||
|
||||
def _watch_links() -> None:
|
||||
"""Heartbeat for the link handler scripts (FramePort runs) + hand queued install links to the window."""
|
||||
"""Heartbeat for the link handler scripts (FramePort runs) + hand queued install links to the window; a second
|
||||
start of FramePort brings this window to the front (core/instance.py)."""
|
||||
from .. import urlhandler
|
||||
from ..core import instance
|
||||
|
||||
while not _link_state["closing"]:
|
||||
urlhandler.heartbeat()
|
||||
app = _link_state["owner"]
|
||||
if app is not None and instance.take_focus_request(): # FramePort was started again: show this window
|
||||
app.page.run_task(app.bring_to_front)
|
||||
if app is not None:
|
||||
for link in urlhandler.take_links():
|
||||
applog.log.info("install link received")
|
||||
@@ -183,6 +187,13 @@ class FramePortApp:
|
||||
atexit.register(_stop_link_watch)
|
||||
self.run_bg(urlhandler.apply_setting) # framedrop:// + frameport:// → FramePort (Settings → General)
|
||||
|
||||
async def bring_to_front(self) -> None:
|
||||
win = self.page.window
|
||||
if win.minimized:
|
||||
win.minimized = False
|
||||
C.update(self.page)
|
||||
await win.to_front()
|
||||
|
||||
# ================================================================== shell
|
||||
def _build_shell(self) -> None:
|
||||
"""The window: sidebar (logo, navigation, update/activity/Frame cards), the view area and the activity panel.
|
||||
@@ -2356,6 +2367,8 @@ class FramePortApp:
|
||||
self._sessions_busy, target = True, self.target
|
||||
|
||||
def work():
|
||||
found = [] # games whose session has something to try: one pop-up for all of them (a burst at the
|
||||
# first connection used to show one per game, and the Library flickered under them)
|
||||
try:
|
||||
for pkg, lp, fetch in due:
|
||||
if not fetch:
|
||||
@@ -2368,22 +2381,36 @@ class FramePortApp:
|
||||
if isinstance(exc, AgentFailed): # the agent answered: asking again won't help
|
||||
pipeline.mark_session_checked(pkg, lp["end"])
|
||||
continue
|
||||
self._session_triaged(pkg, summary)
|
||||
if self._session_triaged(pkg, summary):
|
||||
found.append(pkg)
|
||||
finally:
|
||||
self._sessions_busy = False
|
||||
self._announce_sessions(found)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _session_triaged(self, pkg: str, summary: dict) -> None:
|
||||
def _session_triaged(self, pkg: str, summary: dict) -> bool:
|
||||
"""True when the session has something to try (shown on the game's page; _announce_sessions tells the user)."""
|
||||
from ..validate.session import shown_findings
|
||||
|
||||
if not summary or not shown_findings(summary):
|
||||
return
|
||||
return False
|
||||
if self.route[:2] == ("game", pkg):
|
||||
self.refresh_view()
|
||||
else:
|
||||
return True
|
||||
|
||||
def _announce_sessions(self, packages: list[str]) -> None:
|
||||
"""One pop-up for the play sessions that have something to try (not for the game page already shown)."""
|
||||
packages = [p for p in packages if self.route[:2] != ("game", p)]
|
||||
if len(packages) == 1:
|
||||
pkg = packages[0]
|
||||
self.toast(tr("FramePort looked at your last session of {title} and has something to try.")
|
||||
.format(title=self._title(pkg)), action=tr("Open game"),
|
||||
on_action=lambda e: self.open_game(pkg))
|
||||
elif packages:
|
||||
self.toast(tr_n("FramePort looked at your last session of {n} game and has something to try.",
|
||||
"FramePort looked at the last sessions of {n} games and has something to try on their "
|
||||
"pages.", len(packages)), action=tr("Open {title}").format(title=self._title(packages[0])),
|
||||
on_action=lambda e: self.open_game(packages[0]))
|
||||
|
||||
def apply_session_fix(self, pkg: str, suggestions: list[str], finding: str | None = None) -> None:
|
||||
"""A fix from the game page's "Last session": FrameBridge settings are written on the Frame at once (no
|
||||
@@ -2875,6 +2902,12 @@ def main(argv=None):
|
||||
if links and urlhandler.app_running():
|
||||
return # the open window takes it
|
||||
applog.setup("gui")
|
||||
from ..core import instance
|
||||
|
||||
if not instance.claim(urlhandler.app_running):
|
||||
applog.log.info("FramePort is already running: brought its window to the front")
|
||||
print("FramePort is already running.", file=sys.stderr)
|
||||
return
|
||||
serialize_flet_updates()
|
||||
from ..core import library
|
||||
from .updater import apply_pending_at_start
|
||||
@@ -2889,6 +2922,14 @@ def main(argv=None):
|
||||
if updates.install_kind() == "bundle": # a packaged app finds artwork by file path, not by URL (GitHub #16)
|
||||
thumbs.use_file_paths(True)
|
||||
ft.run(lambda page: FramePortApp(page), assets_dir=assets_dir())
|
||||
# the window is gone: end the process now. Background threads (re-analysis, session triage) would otherwise keep
|
||||
# it running without a window, holding the GUI lock; library writes are atomic and installs resume.
|
||||
_stop_link_watch()
|
||||
import logging
|
||||
import os
|
||||
|
||||
logging.shutdown()
|
||||
os._exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -143,7 +143,7 @@ class Updater:
|
||||
tr("FramePort {version} is available").format(version=up.version),
|
||||
_changes_column(page, changes, intro), size="l", height=_changes_height(changes, 100),
|
||||
actions=[C.ghost(tr("Skip this version"), on_click=skip),
|
||||
C.ghost(tr("Release page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
|
||||
C.ghost(tr("Release page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: _open_url(page, up.page)),
|
||||
C.primary(tr("Update now"), ft.Icons.SYSTEM_UPDATE_ROUNDED, go)]))
|
||||
|
||||
def show_news(self) -> None:
|
||||
@@ -161,7 +161,7 @@ class Updater:
|
||||
page = self.app.page
|
||||
if not entries:
|
||||
self.app.toast(tr("Couldn't load the changelog — check the internet connection"), error=True,
|
||||
action=tr("Releases"), on_action=lambda e: page.launch_url(f"{REPO_URL}/releases"))
|
||||
action=tr("Releases"), on_action=lambda e: _open_url(page, f"{REPO_URL}/releases"))
|
||||
return
|
||||
self.app.page.run_thread(lambda: page.show_dialog(_changes_dialog(page, tr("FramePort changelog"),
|
||||
entries, current=__version__)))
|
||||
@@ -199,7 +199,7 @@ class Updater:
|
||||
tr("Install dev build {version}?").format(version=up.version),
|
||||
_changes_column(page, changes, warn), size="l", height=_changes_height(changes, 160),
|
||||
actions=[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
C.ghost(tr("Build page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
|
||||
C.ghost(tr("Build page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: _open_url(page, up.page)),
|
||||
C.primary(tr("Install dev build"), G.TEST, go)]))
|
||||
|
||||
# ---------------------------------------------------------------- installing
|
||||
@@ -214,7 +214,7 @@ class Updater:
|
||||
if not target or not updates.can_replace(target):
|
||||
self.app.toast(tr("FramePort can't update itself in {value} (no permission) — opening the release "
|
||||
"page to download it").format(value=target or 'this folder'), error=True)
|
||||
page.launch_url(up.page)
|
||||
_open_url(page, up.page)
|
||||
return
|
||||
if kind == "source" and updates.source_is_dirty():
|
||||
self.app.toast(tr("This source checkout has uncommitted changes — commit or stash them, then update"),
|
||||
@@ -301,7 +301,7 @@ def _entry_section(page: ft.Page, entry: updates.ChangelogEntry, expanded: bool,
|
||||
notes = ft.Container(
|
||||
ft.Markdown(entry.body.strip() or tr("No release notes."), selectable=True,
|
||||
extension_set=ft.MarkdownExtensionSet.GITHUB_WEB, md_style_sheet=_notes_style(),
|
||||
on_tap_link=lambda e: page.launch_url(e.data)),
|
||||
on_tap_link=lambda e: _open_url(page, e.data)),
|
||||
padding=ft.Padding(T.S4, 0, T.S4, T.S3), visible=expanded)
|
||||
chevron = ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED if expanded else ft.Icons.CHEVRON_RIGHT_ROUNDED, size=T.px(18),
|
||||
color=T.TEXT_3)
|
||||
@@ -338,12 +338,25 @@ def _changes_height(entries: list[updates.ChangelogEntry], extra: int) -> float:
|
||||
return T.px(min(480, extra + 26 * lines + 50 * len(entries)))
|
||||
|
||||
|
||||
def _open_url(page: ft.Page, url: str) -> None:
|
||||
"""Open a link in the browser. Flet 1.0 has no Page.launch_url (GitHub #194: "All releases" raised
|
||||
AttributeError); same path as FramePortApp.open_url."""
|
||||
from ..core import winhost
|
||||
|
||||
if winhost.is_wsl() and winhost.open_url(url): # the desktop client would open a Linux browser in WSL
|
||||
return
|
||||
try:
|
||||
page.run_task(ft.UrlLauncher().launch_url, url)
|
||||
except Exception: # noqa: BLE001
|
||||
winhost.open_url(url)
|
||||
|
||||
|
||||
def _changes_dialog(page: ft.Page, title: str, entries: list[updates.ChangelogEntry],
|
||||
current: str | None = None) -> ft.AlertDialog:
|
||||
return C.dialog(title, _changes_column(page, entries, None, current), size="l",
|
||||
height=_changes_height(entries, 20),
|
||||
actions=[C.ghost(tr("All releases"), ft.Icons.OPEN_IN_NEW_ROUNDED,
|
||||
lambda e: page.launch_url(f"{REPO_URL}/releases")),
|
||||
lambda e: _open_url(page, f"{REPO_URL}/releases")),
|
||||
C.primary(tr("Close"), on_click=lambda e: page.pop_dialog())])
|
||||
|
||||
|
||||
|
||||
@@ -480,12 +480,27 @@ def _extract(archive: Path, dest: Path, platform: str) -> Path:
|
||||
return folder
|
||||
|
||||
|
||||
# set by the running app's Flutter host / embedded Python for *this* process (its server socket, its temporary
|
||||
# extraction folder, …): a relaunched FramePort that inherits them talks to the old, gone app and its window stays
|
||||
# black (GitHub #195)
|
||||
APP_ENV_PREFIXES = ("FLET_", "SERIOUS_PYTHON")
|
||||
APP_ENV_NAMES = ("PYTHONHOME", "PYTHONPATH", "PYTHONNOUSERSITE", "PYTHONINSPECT", "PYTHONDONTWRITEBYTECODE")
|
||||
|
||||
|
||||
def clean_env(env: dict | None = None) -> dict:
|
||||
"""The environment for the swap script and the FramePort it relaunches: without the running app's own Flet and
|
||||
embedded-Python variables."""
|
||||
env = dict(os.environ if env is None else env)
|
||||
return {k: v for k, v in env.items()
|
||||
if not k.upper().startswith(APP_ENV_PREFIXES) and k.upper() not in APP_ENV_NAMES}
|
||||
|
||||
|
||||
def _powershell() -> tuple[str, dict]:
|
||||
"""Windows PowerShell 5.1 (always installed) and an environment it can load its own modules in: started from
|
||||
PowerShell 7 (pwsh), PSModulePath points at pwsh's modules and 5.1 fails to load Microsoft.PowerShell.Security /
|
||||
.Management (Get-AuthenticodeSignature, Copy-Item). Without PSModulePath it rebuilds its default."""
|
||||
exe = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe"
|
||||
env = {k: v for k, v in os.environ.items() if k.upper() != "PSMODULEPATH"}
|
||||
env = {k: v for k, v in clean_env().items() if k.upper() != "PSMODULEPATH"}
|
||||
return (str(exe) if exe.exists() else "powershell"), env
|
||||
|
||||
|
||||
@@ -683,7 +698,8 @@ def apply(app: Path, target: Path | None = None, relaunch: bool = True, pid: int
|
||||
"Hidden", "-File", str(script)], env=env)
|
||||
else:
|
||||
proc = subprocess.Popen(["/bin/sh", str(script)], start_new_session=not wait, close_fds=True,
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
env=clean_env())
|
||||
if not wait: # make sure the script really runs before FramePort quits (it logs first thing)
|
||||
deadline = time.time() + START_TIMEOUT
|
||||
while time.time() < deadline:
|
||||
|
||||
@@ -324,3 +324,11 @@ def test_agent_collect_diag(monkeypatch, tmp_path):
|
||||
assert f["launch.log"].startswith("[... first 4000 bytes cut") and "files" not in json.loads(f["deployment.json"])
|
||||
assert res["listing"]["roots"]
|
||||
assert a.cmd_collect_diag({"package": "com.not.installed"})["installed"] is False
|
||||
|
||||
|
||||
def test_updater_never_uses_page_launch_url():
|
||||
"""GitHub #194: Flet 1.0's Page has no launch_url ("All releases" raised AttributeError)."""
|
||||
from pathlib import Path
|
||||
|
||||
src = Path(__file__).resolve().parents[1] / "src" / "frameport"
|
||||
assert not [p for p in src.rglob("*.py") if "page.launch_url(" in p.read_text(encoding="utf-8")]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""One FramePort window at a time (core/instance.py)."""
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
from frameport.core import instance
|
||||
|
||||
|
||||
def _hold_lock(path):
|
||||
"""A separate process holding the GUI lock (like a running FramePort)."""
|
||||
code = textwrap.dedent(f"""
|
||||
import sys, time
|
||||
sys.path.insert(0, {str(instance.__file__.rsplit('/frameport/', 1)[0])!r})
|
||||
from pathlib import Path
|
||||
from frameport.core import instance
|
||||
assert instance.acquire(Path({str(path)!r}))
|
||||
print("locked", flush=True)
|
||||
time.sleep(30)
|
||||
""")
|
||||
p = subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE, text=True)
|
||||
assert p.stdout.readline().strip() == "locked"
|
||||
return p
|
||||
|
||||
|
||||
def test_second_start_is_refused_while_a_window_is_open(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FRAMEPORT_HOME", str(tmp_path))
|
||||
holder = _hold_lock(instance.lock_file())
|
||||
try:
|
||||
assert instance.holder() == holder.pid
|
||||
assert not instance.acquire()
|
||||
assert instance.claim(lambda: True) is False # a window answers: it's asked to come to the front
|
||||
assert instance.take_focus_request() and not instance.take_focus_request()
|
||||
# no window answers, but the holder isn't a FramePort GUI (or is still starting): it's left alone
|
||||
assert instance.claim(lambda: False, retries=1) is False
|
||||
assert holder.poll() is None
|
||||
finally:
|
||||
holder.kill()
|
||||
holder.wait()
|
||||
assert instance.acquire() # released by the OS when the holder ends
|
||||
|
||||
|
||||
def test_leftover_frameport_gui_is_ended(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FRAMEPORT_HOME", str(tmp_path))
|
||||
holder = _hold_lock(instance.lock_file())
|
||||
monkeypatch.setattr(instance, "_is_frameport_gui", lambda pid: pid == holder.pid)
|
||||
monkeypatch.setattr(instance, "STARTUP_GRACE", 0.0)
|
||||
try:
|
||||
assert instance.claim(lambda: False) is True # no window: the leftover process is ended, the lock taken
|
||||
assert holder.wait(5) is not None
|
||||
finally:
|
||||
if holder.poll() is None:
|
||||
holder.kill()
|
||||
@@ -411,3 +411,13 @@ def test_pending_news_shows_once_and_waits_while_offline():
|
||||
library.set_setting("update.last_seen_version", None)
|
||||
library.set_setting("update.last_check", 1.0) # updated from an older FramePort
|
||||
assert [e.version for e in updates.pending_news("0.9.2", entries)] == ["0.9.2"]
|
||||
|
||||
|
||||
def test_relaunch_environment_drops_the_running_apps_own_variables():
|
||||
"""GitHub #195: the relaunched app inherited the old app's Flet/embedded-Python variables and stayed black."""
|
||||
from frameport import updates
|
||||
|
||||
env = updates.clean_env({"PATH": "/bin", "FLET_SERVER_UDS_PATH": "/tmp/old.sock", "flet_platform": "windows",
|
||||
"SERIOUS_PYTHON_SITE_PACKAGES": "x", "PYTHONHOME": "C:/Temp/serious_python_temp",
|
||||
"PYTHONPATH": "y", "USERPROFILE": "C:/Users/u", "PSModulePath": "z"})
|
||||
assert env == {"PATH": "/bin", "USERPROFILE": "C:/Users/u", "PSModulePath": "z"}
|
||||
Reference in new issue
Block a user