近年、python単体でwebアプリ開発できるフレームワークが増えているらしい。
HTMLコンテンツをGUIで表示できるpywebviewと組み合わせることで、pythonにおけるデスクトップアプリ開発の選択肢が大幅に広がりそうな気がする。
特にマルチウィンドウで動作させられるとデスクトップアプリとしての意味も出そうなので、まずはreflexで試す。
pywebview、reflexについてはそれぞれ次の記事を参照。
なお、webアプリフレームワークのNiceGuiはpywebviewを利用することでデスクトップアプリ開発もサポートしているらしい。
参考: https://nicegui.io/documentation/section_configuration_deployment#native_mode
基本的な方針
- reflexを別プロセスで動作させ、URL指定でpywebviewにその内容を表示する
- ウィンドウ操作はプロセス間通信を通してpywebview側のプロセスで実行する
ソースコード
フォルダ構成
実験用に、reflex init --name demo_app --template blankで生成されたファイルにpywebview用のmain.pyを追加し、demo_app.pyを編集した。
.
├─main.py
├─rxconfig.py
├─requirements.txt
└─demo_app
├─__init__.py
└─demo_app.py
main.py
reflex、pywebviewの両方を起動、協調させるためのコード。
def main():
server_process = subprocess.Popen(
[sys.executable, "-m", "reflex", "run", f"--frontend-port={REFLEX_PORT}"],
cwd=".",
shell=False,
)
atexit.register(lambda p=server_process: kill_process(p))
if not wait_for_server("127.0.0.1", REFLEX_PORT):
return
threading.Thread(target=recv_socket_message, daemon=True).start()
webview.create_window(
title="Multi Window Demo",
url=f"http://127.0.0.1:{REFLEX_PORT}",
x=0,
y=0,
width=800,
height=400,
)
webview.start(debug=False)
def recv_socket_message():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
atexit.register(sock.close)
sock.bind(("127.0.0.1", SOCK_PORT))
sock.listen()
while True:
try:
conn, addr = sock.accept()
data = conn.recv(1024)
conn.close()
except Exception:
traceback.print_exc()
continue
if not data:
continue
msg = data.decode()
print(f"Received message from server: {msg}")
webview.create_window(
title="Sub Window",
url=f"http://127.0.0.1:{REFLEX_PORT}/incrementer",
width=400,
height=200,
x=400,
y=200,
)
説明:
- reflexは
subprocess.Popen()で別プロセスで動作 - reflex側とののプロセス通信は
threading.Thread()で別スレッドで実施- 今回はプロセス通信方法としてソケット通信を採用
- メッセージを通してウィンドウを操作することで、reflex側と協調
コード全体
import atexit
import os
import signal
import socket
import subprocess
import sys
import threading
import time
import traceback
import requests
import webview
REFLEX_PORT = 3000
SOCK_PORT = 65432
def main():
server_process = subprocess.Popen(
[sys.executable, "-m", "reflex", "run", f"--frontend-port={REFLEX_PORT}"],
cwd=".",
shell=False,
)
atexit.register(lambda p=server_process: kill_process(p))
if not wait_for_server("127.0.0.1", REFLEX_PORT):
return
threading.Thread(target=recv_socket_message, daemon=True).start()
webview.create_window(
title="Multi Window Demo",
url=f"http://127.0.0.1:{REFLEX_PORT}",
x=0,
y=0,
width=800,
height=400,
)
webview.start(debug=False)
def kill_process(process: subprocess.Popen):
os.kill(process.pid, signal.CTRL_C_EVENT)
print(f"Sending ctrl+c event to process {process.pid}.")
def wait_for_server(
host: str = "127.0.0.1", port: int = REFLEX_PORT, max_retries: int = 60
) -> bool:
print("Checking HTTP response...")
for i in range(max_retries):
try:
response = requests.get(f"http://{host}:{port}/", timeout=2)
print(f"Server responded with status {response.status_code}")
return True
except requests.exceptions.ConnectionError:
pass
except Exception as e:
print(f"Request error: {e}")
time.sleep(1)
print("Failed to get HTTP response")
return False
def recv_socket_message():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
atexit.register(sock.close)
sock.bind(("127.0.0.1", SOCK_PORT))
sock.listen()
while True:
try:
conn, addr = sock.accept()
data = conn.recv(1024)
conn.close()
except Exception:
traceback.print_exc()
continue
if not data:
continue
msg = data.decode()
print(f"Received message from server: {msg}")
webview.create_window(
title="Sub Window",
url=f"http://127.0.0.1:{REFLEX_PORT}/incrementer",
width=400,
height=200,
x=400,
y=200,
)
if __name__ == "__main__":
main()
demo_app/demo_app.py
アプリで表示する複数のウィンドウを実装したもの。
import socket
import reflex as rx
SOCK_PORT = 65432
def send_message(message: str):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("127.0.0.1", SOCK_PORT))
sock.sendall(message.encode())
sock.close()
class State(rx.State):
count: str = rx.LocalStorage("0", sync=True)
@rx.event
def create_incrementer_window(self):
send_message("create_incrementer_window")
@rx.event
def increment(self):
self.count = str(int(self.count) + 1)
def index() -> rx.Component:
# Main window
return rx.container(
rx.vstack(
rx.heading("Multi Window Demo", size="9"),
rx.text("Count: ", State.count, size="5"),
rx.button("Create Incrementer", on_click=State.create_incrementer_window),
),
)
def incrementer() -> rx.Component:
# Sub window
return rx.container(
rx.vstack(
rx.heading("Incrementer Window", size="6"),
rx.text("Count: ", State.count, size="5"),
rx.button("Increment", on_click=State.increment()),
),
)
app = rx.App()
app.add_page(index)
app.add_page(incrementer)
説明:
-
rx.App()にindexとincrementerページをそれぞれ登録-
indexページがメインウィンドウ、incrementerページがサブウィンドウに対応
-
-
State.create_incrementer_windowイベントでソケットにメッセージを送り、メインプロセス(pywebview)へサブウィンドウ生成を依頼 -
rx.Stateクラスを継承したクラスのプロパティにrx.LocalStorage(..., sync=True)を指定することで、複数ウィンドウ間で状態を同期
動作イメージ
図. マルチウィンドウの動作イメージ。以下の操作を行っている。
-
python main.pyでGUIを起動 - メインウィンドウのボタンクリックでサブウィンドウを生成
- サブウィンドウのボタンクリックでカウンターの値をインクリメント
マルチウィンドウの動作、状態の共有ともに問題なくできていることが分かる。
まとめ
pywebviewを使えばwebアプリを気軽にデスクトップアプリ化できることが分かった。
特に、プロセス通信を経由すればウィンドウ操作なども自由にできる。
今回はreflexを試したが、他のwebアプリでも同様にできるものと思われる。
