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