1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPLv3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net> 4 5 6import os 7import struct 8import subprocess 9import sys 10from threading import Thread 11from uuid import uuid4 12from contextlib import suppress 13 14 15from polyglot.builtins import string_or_bytes 16 17is64bit = sys.maxsize > (1 << 32) 18base = sys.extensions_location if hasattr(sys, 'new_app_layout') else os.path.dirname(sys.executable) 19HELPER = os.path.join(base, 'calibre-file-dialog.exe') 20current_app_uid = None 21 22 23def set_app_uid(val=None): 24 global current_app_uid 25 current_app_uid = val 26 27 28def is_ok(): 29 return os.path.exists(HELPER) 30 31 32try: 33 from calibre.utils.config import dynamic 34except ImportError: 35 dynamic = {} 36 37 38def get_hwnd(widget=None): 39 ewid = None 40 if widget is not None: 41 ewid = widget.effectiveWinId() 42 if ewid is None: 43 return None 44 return int(ewid) 45 46 47def serialize_hwnd(hwnd): 48 if hwnd is None: 49 return b'' 50 return struct.pack('=B4s' + ('Q' if is64bit else 'I'), 4, b'HWND', int(hwnd)) 51 52 53def serialize_secret(secret): 54 return struct.pack('=B6s32s', 6, b'SECRET', secret) 55 56 57def serialize_binary(key, val): 58 key = key.encode('ascii') if not isinstance(key, bytes) else key 59 return struct.pack('=B%ssB' % len(key), len(key), key, int(val)) 60 61 62def serialize_string(key, val): 63 key = key.encode('ascii') if not isinstance(key, bytes) else key 64 val = str(val).encode('utf-8') 65 if len(val) > 2**16 - 1: 66 raise ValueError('%s is too long' % key) 67 return struct.pack('=B%dsH%ds' % (len(key), len(val)), len(key), key, len(val), val) 68 69 70def serialize_file_types(file_types): 71 key = b"FILE_TYPES" 72 buf = [struct.pack('=B%dsH' % len(key), len(key), key, len(file_types))] 73 74 def add(x): 75 x = x.encode('utf-8').replace(b'\0', b'') 76 buf.append(struct.pack('=H%ds' % len(x), len(x), x)) 77 for name, extensions in file_types: 78 add(name or _('Files')) 79 if isinstance(extensions, string_or_bytes): 80 extensions = extensions.split() 81 add('; '.join('*.' + ext.lower() for ext in extensions)) 82 return b''.join(buf) 83 84 85class Helper(Thread): 86 87 def __init__(self, process, data, callback): 88 Thread.__init__(self, name='FileDialogHelper') 89 self.process = process 90 self.callback = callback 91 self.data = data 92 self.daemon = True 93 self.rc = 1 94 self.stdoutdata = self.stderrdata = b'' 95 96 def run(self): 97 try: 98 self.stdoutdata, self.stderrdata = self.process.communicate(b''.join(self.data)) 99 self.rc = self.process.wait() 100 finally: 101 self.callback() 102 103 104def process_path(x): 105 if isinstance(x, bytes): 106 x = os.fsdecode(x) 107 return os.path.abspath(os.path.expanduser(x)) 108 109 110def select_initial_dir(q): 111 while q: 112 c = os.path.dirname(q) 113 if c == q: 114 break 115 if os.path.exists(c): 116 return c 117 q = c 118 return os.path.expanduser('~') 119 120 121def run_file_dialog( 122 parent=None, title=None, initial_folder=None, filename=None, save_path=None, 123 allow_multiple=False, only_dirs=False, confirm_overwrite=True, save_as=False, no_symlinks=False, 124 file_types=(), default_ext=None, app_uid=None 125): 126 from calibre.gui2 import sanitize_env_vars 127 secret = os.urandom(32).replace(b'\0', b' ') 128 pipename = '\\\\.\\pipe\\%s' % uuid4() 129 data = [serialize_string('PIPENAME', pipename), serialize_secret(secret)] 130 parent = parent or None 131 if parent is not None: 132 data.append(serialize_hwnd(get_hwnd(parent))) 133 if title: 134 data.append(serialize_string('TITLE', title)) 135 if no_symlinks: 136 data.append(serialize_binary('NO_SYMLINKS', no_symlinks)) 137 if save_as: 138 data.append(serialize_binary('SAVE_AS', save_as)) 139 if confirm_overwrite: 140 data.append(serialize_binary('CONFIRM_OVERWRITE', confirm_overwrite)) 141 if save_path is not None: 142 save_path = process_path(save_path) 143 if os.path.exists(save_path): 144 data.append(serialize_string('SAVE_PATH', save_path)) 145 else: 146 if not initial_folder: 147 initial_folder = select_initial_dir(save_path) 148 if not filename: 149 filename = os.path.basename(save_path) 150 else: 151 if allow_multiple: 152 data.append(serialize_binary('MULTISELECT', allow_multiple)) 153 if only_dirs: 154 data.append(serialize_binary('ONLY_DIRS', only_dirs)) 155 if initial_folder is not None: 156 initial_folder = process_path(initial_folder) 157 if os.path.isdir(initial_folder): 158 data.append(serialize_string('FOLDER', initial_folder)) 159 if filename: 160 if isinstance(filename, bytes): 161 filename = os.fsdecode(filename) 162 data.append(serialize_string('FILENAME', filename)) 163 if only_dirs: 164 file_types = () # file types not allowed for dir only dialogs 165 elif not file_types: 166 file_types = [(_('All files'), ('*',))] 167 if file_types: 168 data.append(serialize_file_types(file_types)) 169 if default_ext: 170 data.append(serialize_string('DEFAULT_EXTENSION', default_ext)) 171 app_uid = app_uid or current_app_uid 172 if app_uid: 173 data.append(serialize_string('APP_UID', app_uid)) 174 175 from qt.core import QEventLoop, Qt, pyqtSignal 176 177 class Loop(QEventLoop): 178 179 dialog_closed = pyqtSignal() 180 181 def __init__(self): 182 QEventLoop.__init__(self) 183 self.dialog_closed.connect(self.exit, type=Qt.ConnectionType.QueuedConnection) 184 185 loop = Loop() 186 server = PipeServer(pipename) 187 server.start() 188 with sanitize_env_vars(): 189 h = Helper(subprocess.Popen( 190 [HELPER], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE), 191 data, loop.dialog_closed.emit) 192 h.start() 193 loop.exec(QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents) 194 195 def decode(x): 196 x = x or b'' 197 try: 198 x = x.decode('utf-8') 199 except Exception: 200 x = repr(x) 201 return x 202 203 def get_errors(): 204 return decode(h.stdoutdata) + ' ' + decode(h.stderrdata) 205 from calibre import prints 206 from calibre.constants import DEBUG 207 if DEBUG: 208 prints('stdout+stderr from file dialog helper:', str([h.stdoutdata, h.stderrdata])) 209 210 if h.rc != 0: 211 raise Exception('File dialog failed (return code %s): %s' % (h.rc, get_errors())) 212 server.join(2) 213 if server.is_alive(): 214 raise Exception('Timed out waiting for read from pipe to complete') 215 if server.err_msg: 216 raise Exception(server.err_msg) 217 if not server.data: 218 return () 219 parts = list(filter(None, server.data.split(b'\0'))) 220 if DEBUG: 221 prints('piped data from file dialog helper:', str(parts)) 222 if len(parts) < 2: 223 return () 224 if parts[0] != secret: 225 raise Exception('File dialog failed, incorrect secret received: ' + get_errors()) 226 227 from calibre_extensions.winutil import get_long_path_name 228 229 def fix_path(x): 230 u = os.path.abspath(x.decode('utf-8')) 231 with suppress(Exception): 232 try: 233 return get_long_path_name(u) 234 except FileNotFoundError: 235 base, fn = os.path.split(u) 236 return os.path.join(get_long_path_name(base), fn) 237 return u 238 239 ans = tuple(map(fix_path, parts[1:])) 240 return ans 241 242 243def get_initial_folder(name, title, default_dir='~', no_save_dir=False): 244 name = name or 'dialog_' + title 245 if no_save_dir: 246 initial_folder = os.path.expanduser(default_dir) 247 else: 248 initial_folder = dynamic.get(name, os.path.expanduser(default_dir)) 249 if not initial_folder or not os.path.isdir(initial_folder): 250 initial_folder = select_initial_dir(initial_folder) 251 return name, initial_folder 252 253 254def choose_dir(window, name, title, default_dir='~', no_save_dir=False): 255 name, initial_folder = get_initial_folder(name, title, default_dir, no_save_dir) 256 ans = run_file_dialog(window, title, only_dirs=True, initial_folder=initial_folder) 257 if ans: 258 ans = ans[0] 259 if not no_save_dir: 260 dynamic.set(name, ans) 261 return ans 262 263 264def choose_files(window, name, title, 265 filters=(), all_files=True, select_only_single_file=False, default_dir='~'): 266 name, initial_folder = get_initial_folder(name, title, default_dir) 267 file_types = list(filters) 268 if all_files: 269 file_types.append((_('All files'), ['*'])) 270 ans = run_file_dialog(window, title, allow_multiple=not select_only_single_file, initial_folder=initial_folder, file_types=file_types) 271 if ans: 272 dynamic.set(name, os.path.dirname(ans[0])) 273 return ans 274 return None 275 276 277def choose_images(window, name, title, select_only_single_file=True, formats=None): 278 if formats is None: 279 from calibre.gui2.dnd import image_extensions 280 formats = image_extensions() 281 file_types = [(_('Images'), list(formats))] 282 return choose_files(window, name, title, select_only_single_file=select_only_single_file, filters=file_types) 283 284 285def choose_save_file(window, name, title, filters=[], all_files=True, initial_path=None, initial_filename=None): 286 no_save_dir = False 287 default_dir = '~' 288 filename = initial_filename 289 if initial_path is not None: 290 no_save_dir = True 291 default_dir = select_initial_dir(initial_path) 292 filename = os.path.basename(initial_path) 293 file_types = list(filters) 294 if all_files: 295 file_types.append((_('All files'), ['*'])) 296 all_exts = [] 297 for ftext, exts in file_types: 298 for ext in exts: 299 if '*' not in ext: 300 all_exts.append(ext.lower()) 301 default_ext = all_exts[0] if all_exts else None 302 name, initial_folder = get_initial_folder(name, title, default_dir, no_save_dir) 303 ans = run_file_dialog(window, title, save_as=True, initial_folder=initial_folder, filename=filename, file_types=file_types, default_ext=default_ext) 304 if ans: 305 ans = ans[0] 306 if not no_save_dir: 307 dynamic.set(name, ans) 308 return ans 309 310 311class PipeServer(Thread): 312 313 def __init__(self, pipename): 314 Thread.__init__(self, name='PipeServer', daemon=True) 315 from calibre_extensions import winutil 316 self.client_connected = False 317 self.pipe_handle = winutil.create_named_pipe( 318 pipename, winutil.PIPE_ACCESS_INBOUND | winutil.FILE_FLAG_FIRST_PIPE_INSTANCE, 319 winutil.PIPE_TYPE_BYTE | winutil.PIPE_READMODE_BYTE | winutil.PIPE_WAIT | winutil.PIPE_REJECT_REMOTE_CLIENTS, 320 1, 8192, 8192, 0) 321 winutil.set_handle_information(self.pipe_handle, winutil.HANDLE_FLAG_INHERIT, 0) 322 self.err_msg = None 323 self.data = b'' 324 325 def run(self): 326 from calibre_extensions import winutil 327 try: 328 try: 329 winutil.connect_named_pipe(self.pipe_handle) 330 except Exception as err: 331 self.err_msg = f'ConnectNamedPipe failed: {err}' 332 return 333 334 self.client_connected = True 335 while True: 336 try: 337 data = winutil.read_file(self.pipe_handle, 64 * 1024) 338 except OSError as err: 339 if err.winerror == winutil.ERROR_BROKEN_PIPE: 340 break # pipe was closed at the other end 341 self.err_msg = f'ReadFile on pipe failed: {err}' 342 if not data: 343 break 344 self.data += data 345 finally: 346 self.pipe_handle = None 347 348 349def test(helper=HELPER): 350 pipename = '\\\\.\\pipe\\%s' % uuid4() 351 echo = '\U0001f431 Hello world!' 352 secret = os.urandom(32).replace(b'\0', b' ') 353 data = serialize_string('PIPENAME', pipename) + serialize_string('ECHO', echo) + serialize_secret(secret) 354 server = PipeServer(pipename) 355 server.start() 356 p = subprocess.Popen([helper], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE) 357 stdout, stderr = p.communicate(data) 358 if p.wait() != 0: 359 raise Exception('File dialog failed: ' + stdout.decode('utf-8') + ' ' + stderr.decode('utf-8')) 360 if server.err_msg is not None: 361 raise RuntimeError(server.err_msg) 362 server.join(2) 363 parts = list(filter(None, server.data.split(b'\0'))) 364 if parts[0] != secret: 365 raise RuntimeError('Did not get back secret: %r != %r' % (secret, parts[0])) 366 q = parts[1].decode('utf-8') 367 if q != echo: 368 raise RuntimeError('Unexpected response: %r' % server.data) 369 370 371if __name__ == '__main__': 372 from calibre.gui2 import Application 373 app = Application([]) 374 print(choose_save_file(None, 'xxx', 'yyy')) 375 del app 376