Skip to content

Commit

Permalink
gui: fix hang on exit on Linux
Browse files Browse the repository at this point in the history
  • Loading branch information
layday committed May 18, 2024
1 parent 4b3738e commit e50fa40
Show file tree
Hide file tree
Showing 4 changed files with 99 additions and 96 deletions.
4 changes: 2 additions & 2 deletions instawow-gui/src/instawow_gui/_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def _gui_command(ctx: click.Context) -> None:
from instawow._logging import setup_logging
from instawow.config import GlobalConfig, ProfileConfig

from .app import InstawowApp
from .app import make_app

global_config = GlobalConfig.read().ensure_dirs()
dummy_jsonrpc_config = ProfileConfig.make_dummy_config(
Expand All @@ -23,7 +23,7 @@ def _gui_command(ctx: click.Context) -> None:
params = ctx.find_root().params
setup_logging(dummy_jsonrpc_config.logging_dir, *params['verbose'])

InstawowApp(debug=any(params['verbose']), version=__version__).main_loop()
make_app(version=__version__).main_loop()


@instawow.plugins.hookimpl
Expand Down
118 changes: 53 additions & 65 deletions instawow-gui/src/instawow_gui/app.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
from __future__ import annotations

import asyncio
import concurrent.futures
import json
import sys
from contextlib import suppress
import threading
from collections.abc import Callable
from functools import partial
from typing import cast

import anyio.from_thread
import anyio.to_thread
import toga
import toga.constants
import toga.style.pack

from instawow._logging import logger
from instawow._utils.compat import StrEnum

from . import json_rpc_server

_loop_factory = asyncio.DefaultEventLoopPolicy().new_event_loop


class _TogaSimulateKeypressAction(StrEnum):
ToggleSearchFilter = 'toggleSearchFilter'
Expand All @@ -26,45 +26,47 @@ class _TogaSimulateKeypressAction(StrEnum):
ActivateViewSearch = 'activateViewSearch'


class InstawowApp(toga.App):
def __init__(self, debug: bool, **kwargs) -> None:
self.__debug = debug
class _App(toga.App):
def __start_json_rpc_server(self, on_started: Callable[[str], None]):
def start_json_rpc_server():
async def main():
async with json_rpc_server.run_web_app(
await json_rpc_server.create_web_app(self)
) as server_url:
server_url_future.set_result(str(server_url))
await wait_future

super().__init__(
formal_name='instawow-gui',
app_id='org.instawow.instawow_gui',
app_name='instawow_gui',
icon='resources/instawow_gui',
**kwargs,
)
loop.run_until_complete(main())

def startup(self, **kwargs) -> None:
self.main_window = toga.MainWindow(title=self.formal_name, size=(800, 600))
def stop_json_rpc_server():
loop.call_soon_threadsafe(wait_future.set_result, None)
json_rpc_server_thread.join()
return True

if sys.platform == 'win32':
import ctypes
loop = _loop_factory()
wait_future = loop.create_future()

# Enable high DPI support.
ctypes.windll.user32.SetProcessDPIAware()
server_url_future = concurrent.futures.Future[str]()
server_url_future.add_done_callback(lambda f: on_started(f.result()))

self.main_window.content = web_view = toga.WebView(style=toga.style.pack.Pack(flex=1))
json_rpc_server_thread = threading.Thread(
target=start_json_rpc_server, name='_json_rpc_server'
)
json_rpc_server_thread.start()

if self.__debug:
with suppress(AttributeError):
web_view._impl.native.configuration.preferences.setValue(
True, forKey='developerExtrasEnabled'
)
def on_app_exit(app: toga.App, **kwargs):
return stop_json_rpc_server()

if sys.platform == 'win32':
web_view_impl = web_view._impl
self.on_exit = on_app_exit

def configure_webview2(sender, event_args):
if event_args.IsSuccess:
web_view_impl.native.CoreWebView2.Settings.AreBrowserAcceleratorKeysEnabled = (
False
)
def startup(self) -> None:
self.main_window = toga.MainWindow(size=(800, 600))

if sys.platform == 'win32':
import ctypes

web_view_impl.native.CoreWebView2InitializationCompleted += configure_webview2
# Enable high DPI support.
ctypes.windll.user32.SetProcessDPIAware()

def dispatch_js_keyboard_event(command: toga.Command, **kwargs):
event_args = json.dumps(
Expand All @@ -84,7 +86,7 @@ def dispatch_js_keyboard_event(command: toga.Command, **kwargs):
),
text='Toggle Search Filter',
shortcut=toga.Key.MOD_1 + toga.Key.G,
group=cast(toga.Group, toga.Group.EDIT),
group=toga.Group.EDIT,
section=20,
order=10,
),
Expand All @@ -97,7 +99,7 @@ def dispatch_js_keyboard_event(command: toga.Command, **kwargs):
),
text='Installed',
shortcut=toga.Key.MOD_1 + toga.Key.L,
group=cast(toga.Group, toga.Group.WINDOW),
group=toga.Group.WINDOW,
section=20,
order=10,
),
Expand All @@ -109,7 +111,7 @@ def dispatch_js_keyboard_event(command: toga.Command, **kwargs):
action=_TogaSimulateKeypressAction.ActivateViewReconcile,
),
text='Unreconciled',
group=cast(toga.Group, toga.Group.WINDOW),
group=toga.Group.WINDOW,
section=20,
order=20,
),
Expand All @@ -122,39 +124,25 @@ def dispatch_js_keyboard_event(command: toga.Command, **kwargs):
),
text='Search',
shortcut=toga.Key.MOD_1 + toga.Key.F,
group=cast(toga.Group, toga.Group.WINDOW),
group=toga.Group.WINDOW,
section=20,
order=30,
),
)

async def startup():
async with anyio.from_thread.BlockingPortal() as portal:

def run_json_rpc_server():
async def run():
web_app = await json_rpc_server.create_app((self.main_window, portal))
server_url, serve_forever = await json_rpc_server.run_app(web_app)

logger.debug(f'JSON-RPC server running on {server_url}')

set_server_url = partial(setattr, web_view, 'url')
portal.call(set_server_url, str(server_url))
web_view = toga.WebView(style=toga.style.pack.Pack(flex=1))

await serve_forever()
self.__start_json_rpc_server(partial(setattr, web_view, 'url'))

# We don't want to inherit the parent thread's event loop policy,
# i.e. the rubicon loop on macOS.
policy = asyncio.DefaultEventLoopPolicy()
policy.new_event_loop().run_until_complete(run())

await anyio.to_thread.run_sync(run_json_rpc_server)

main_task = self.loop.create_task(startup())

def on_exit(app: toga.App, **kwargs):
return main_task.cancel()
self.main_window.content = web_view
self.main_window.show()

self.on_exit = on_exit

self.main_window.show()
def make_app(version: str) -> toga.App:
return _App(
formal_name='instawow-gui',
app_id='org.instawow.instawow_gui',
app_name='instawow_gui',
icon='resources/instawow_gui',
version=version,
)
72 changes: 44 additions & 28 deletions instawow-gui/src/instawow_gui/json_rpc_server.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

import asyncio
import concurrent.futures
import contextvars
import enum
import importlib.resources
import json
import os
from collections.abc import Callable, Iterator, Set
from contextlib import AsyncExitStack, contextmanager
from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
from datetime import datetime
from functools import partial
from itertools import chain
Expand All @@ -17,10 +18,10 @@
import aiohttp
import aiohttp.typedefs
import aiohttp.web
import anyio.from_thread
import attrs
import cattrs
import cattrs.preconf.json
import toga
from aiohttp_rpc import JsonRpcMethod
from aiohttp_rpc import middlewares as rpc_middlewares
from aiohttp_rpc.errors import InvalidParams, ServerError
Expand Down Expand Up @@ -51,7 +52,7 @@
_T = TypeVar('_T')
_P = ParamSpec('_P')

_toga_handle = contextvars.ContextVar[tuple[Any, anyio.from_thread.BlockingPortal]]('_toga_handle')
_toga_handle = contextvars.ContextVar[toga.App]('_toga_handle')

_LOCK_PREFIX = object()

Expand Down Expand Up @@ -583,16 +584,24 @@ class SelectFolderParams(BaseParams):
initial_folder: str | None

async def respond(self, config_ctxs: _ConfigBoundCtxCollection) -> SelectFolderResult:
main_window, portal = _toga_handle.get()
app = _toga_handle.get()

async def select_folder() -> Path | None:
return await main_window.select_folder_dialog('Select folder', self.initial_folder)
future = concurrent.futures.Future[Path | None]()

try:
selection = portal.call(select_folder)
except ValueError:
selection = None
return {'selection': selection}
def select_folder():
async def inner() -> Path | None:
try:
return await app.main_window.select_folder_dialog( # pyright: ignore[reportUnknownMemberType]
'Select folder', self.initial_folder
)
except ValueError:
return None

task = asyncio.create_task(inner())
task.add_done_callback(lambda t: future.set_result(t.result()))

app.loop.call_soon_threadsafe(select_folder)
return {'selection': future.result()}


class ConfirmDialogueResult(TypedDict):
Expand All @@ -605,12 +614,19 @@ class ConfirmDialogueParams(BaseParams):
message: str

async def respond(self, config_ctxs: _ConfigBoundCtxCollection) -> ConfirmDialogueResult:
main_window, portal = _toga_handle.get()
app = _toga_handle.get()

async def confirm() -> bool:
return await main_window.confirm_dialog(self.title, self.message)
future = concurrent.futures.Future[bool]()

return {'ok': portal.call(confirm)}
def confirm():
async def inner():
return await app.main_window.confirm_dialog(self.title, self.message)

task = asyncio.create_task(inner())
task.add_done_callback(lambda t: future.set_result(t.result()))

app.loop.call_soon_threadsafe(confirm)
return {'ok': future.result()}


class _ConfigBoundCtxCollection:
Expand Down Expand Up @@ -729,7 +745,7 @@ async def cancel_github_auth_polling(self):
await cancel_tasks([self._github_auth_flow_task])


async def create_app(toga_handle: tuple[Any, anyio.from_thread.BlockingPortal] | None = None):
async def create_web_app(toga_handle: toga.App | None = None):
if toga_handle:
_toga_handle.set(toga_handle)

Expand Down Expand Up @@ -813,7 +829,8 @@ async def enforce_same_origin(
return app


async def run_app(app: aiohttp.web.Application):
@asynccontextmanager
async def run_web_app(app: aiohttp.web.Application):
"Fire up the server."
app_runner = aiohttp.web.AppRunner(app)
await app_runner.setup()
Expand All @@ -823,14 +840,13 @@ async def run_app(app: aiohttp.web.Application):
server = await asyncio.get_running_loop().create_server(app_runner.server, LOCALHOST)
host, port = server.sockets[0].getsockname()

async def serve():
try:
# ``server_forever`` cleans up after the server when it's interrupted
await server.serve_forever()
finally:
await app_runner.cleanup()

return (
URL.build(scheme='http', host=host, port=port),
serve,
)
try:
await server.start_serving()

server_url = URL.build(scheme='http', host=host, port=port)
logger.debug(f'JSON-RPC server running on {server_url}')
yield server_url
finally:
# Fark knows how you're supposed to gracefully stop the server:
# https://github.com/aio-libs/aiohttp/issues/2950
await app_runner.shutdown()
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ dependencies = [
]
optional-dependencies."skeletal-gui" = [
"aiohttp-rpc >= 1.0.0",
"anyio >= 4.3.0",
"toga-core >= 0.4.2",
]
optional-dependencies."gui" = [
Expand Down

0 comments on commit e50fa40

Please sign in to comment.