Coverage for graphica/gui/single_instance.py: 74.4%
101 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""起動中の Graphica にファイルを渡す(ファイルをダブルクリックしたとき、新しいウィンドウではなく新しいタブで開くため)。
3最初に起動したものがローカルサーバーを開き、あとから起動したものはそこへパスを送って終わる。起動画面より前に使うので、
4ここでは PySide6 だけを読む。
5"""
6import getpass
7import json
8import logging
9import os
10import re
12from PySide6.QtCore import QEvent, QObject, Signal
13from PySide6.QtNetwork import QLocalServer, QLocalSocket
15logger = logging.getLogger(__name__)
17# 起動中のアプリがほかの受け渡しを処理している間は待たされることがある
18_CONNECT_TIMEOUT_MS = 2000
19_WRITE_TIMEOUT_MS = 1000
22def server_name():
23 """利用者ごとに別の名前(同じ PC の別の利用者のアプリには渡さない)。"""
24 try:
25 user = getpass.getuser()
26 except (OSError, KeyError, ImportError): # 利用者名が取れない環境でも起動は止めない
27 user = "user"
28 return "Graphica-" + re.sub(r"[^A-Za-z0-9_.-]", "_", user)
31def files_to_open(argv):
32 """起動の引数のうち、開くファイル(オプション以外で、実在するもの)。"""
33 return [os.path.abspath(arg) for arg in argv[1:] if not arg.startswith("-") and os.path.isfile(arg)]
36def instance_running(name=None):
37 """ほかの Graphica が受け渡しの窓口を開いているか。"""
38 socket = QLocalSocket()
39 socket.connectToServer(name or server_name())
40 running = socket.waitForConnected(_CONNECT_TIMEOUT_MS)
41 if running:
42 socket.disconnectFromServer()
43 return running
46def send_to_running_instance(paths, name=None):
47 """起動中のアプリがあればパスを渡して True。無ければ False(自分が起動を続ける)。"""
48 socket = QLocalSocket()
49 socket.connectToServer(name or server_name())
50 if not socket.waitForConnected(_CONNECT_TIMEOUT_MS): 50 ↛ 52line 50 didn't jump to line 52 because the condition on line 50 was always true
51 return False
52 socket.write((json.dumps({"open": list(paths)}) + "\n").encode("utf-8"))
53 socket.flush()
54 # Windows のパイプは書き込みが後から終わる。切断が済むまで待たないと、すぐ終わるこのプロセスと一緒に消える
55 socket.disconnectFromServer()
56 if socket.state() != QLocalSocket.LocalSocketState.UnconnectedState:
57 socket.waitForDisconnected(_WRITE_TIMEOUT_MS)
58 return socket.state() == QLocalSocket.LocalSocketState.UnconnectedState and socket.bytesToWrite() == 0
61class InstanceServer(QObject):
62 """あとから起動したアプリが送ってきたパスを open_requested で知らせる。"""
64 open_requested = Signal(list)
66 def __init__(self, name=None, parent=None):
67 super().__init__(parent)
68 self._name = name or server_name()
69 self._server = QLocalServer(self)
70 self._server.newConnection.connect(self._on_new_connection)
71 self._buffers = {}
73 def listen(self):
74 """開けなければ False。前回の異常終了で残った名前(Unix のソケットファイル)は消して開き直す。"""
75 if self._server.listen(self._name): 75 ↛ 77line 75 didn't jump to line 77 because the condition on line 75 was always true
76 return True
77 QLocalServer.removeServer(self._name)
78 if self._server.listen(self._name):
79 return True
80 logger.warning("起動中のアプリへのファイルの受け渡しを開けませんでした: %s", self._server.errorString())
81 return False
83 def close(self):
84 self._server.close()
86 def _on_new_connection(self):
87 # 接続を掴むラムダは使わない(接続が消えたあとに呼ばれると落ちる)。送り手は sender() で引く
88 while self._server.hasPendingConnections():
89 socket = self._server.nextPendingConnection()
90 self._buffers[socket] = b""
91 socket.readyRead.connect(self._on_ready_read)
92 socket.disconnected.connect(self._on_disconnected)
94 def _on_ready_read(self):
95 socket = self.sender()
96 if socket is None: 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true
97 return
98 self._buffers[socket] = self._buffers.get(socket, b"") + bytes(socket.readAll())
99 self._emit_complete_lines(socket)
101 def _on_disconnected(self):
102 socket = self.sender()
103 if socket is None: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 return
105 self._buffers[socket] = self._buffers.get(socket, b"") + bytes(socket.readAll())
106 self._emit_complete_lines(socket, final=True)
107 self._buffers.pop(socket, None)
108 socket.deleteLater()
110 def _emit_complete_lines(self, socket, final=False):
111 data = self._buffers.get(socket, b"")
112 *lines, rest = data.split(b"\n")
113 if final and rest: 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true
114 lines.append(rest)
115 rest = b""
116 self._buffers[socket] = rest
117 for line in lines:
118 if not line.strip(): 118 ↛ 119line 118 didn't jump to line 119 because the condition on line 118 was never true
119 continue
120 try:
121 paths = json.loads(line.decode("utf-8")).get("open", [])
122 except (ValueError, AttributeError):
123 logger.warning("起動中のアプリへの受け渡しの中身が読めませんでした: %r", line[:200])
124 continue
125 paths = [p for p in paths if isinstance(p, str)]
126 if paths: 126 ↛ 117line 126 didn't jump to line 117 because the condition on line 126 was always true
127 self.open_requested.emit(paths)
130class FileOpenEventFilter(QObject):
131 """macOS で Finder から開いたファイル(QFileOpenEvent。起動中でも届く)を open_requested で知らせる。"""
133 open_requested = Signal(list)
135 def eventFilter(self, watched, event):
136 if event.type() == QEvent.Type.FileOpen:
137 path = event.file()
138 if path: 138 ↛ 140line 138 didn't jump to line 140 because the condition on line 138 was always true
139 self.open_requested.emit([path])
140 return True
141 return super().eventFilter(watched, event)