1# serve.py - TortoiseHg dialog to start web server
2#
3# Copyright 2010 Yuya Nishihara <yuya@tcha.org>
4#
5# This software may be used and distributed according to the terms of the
6# GNU General Public License version 2, incorporated herein by reference.
7
8from __future__ import absolute_import
9
10import os
11import tempfile
12
13from .qtcore import (
14    Qt,
15    pyqtSlot,
16)
17from .qtgui import (
18    QDialog,
19    QSystemTrayIcon,
20)
21
22from mercurial import (
23    error,
24    pycompat,
25    util,
26)
27
28from ..util import (
29    hglib,
30    paths,
31    wconfig,
32)
33from ..util.i18n import _
34from . import (
35    cmdcore,
36    cmdui,
37    qtlib,
38)
39from .serve_ui import Ui_ServeDialog
40from .webconf import WebconfForm
41
42if hglib.TYPE_CHECKING:
43    from typing import (
44        Any,
45        List,
46        Text,
47        Tuple,
48        Optional,
49    )
50    from .qtgui import (
51        QWidget,
52    )
53    from mercurial import (
54        ui as uimod,
55    )
56    from ..util.typelib import (
57        IniConfig,
58    )
59
60
61class ServeDialog(QDialog):
62    """Dialog for serving repositories via web"""
63    def __init__(self, ui, webconf, parent=None):
64        # type: (uimod.ui, Optional[IniConfig], Optional[QWidget]) -> None
65        super(ServeDialog, self).__init__(parent)
66        self.setWindowFlags((self.windowFlags() | Qt.WindowMinimizeButtonHint)
67                            & ~Qt.WindowContextHelpButtonHint)
68        self.setWindowIcon(qtlib.geticon('hg-serve'))
69
70        self._qui = Ui_ServeDialog()
71        self._qui.setupUi(self)
72
73        self._initwebconf(webconf)
74        self._initcmd(ui)
75        self._initactions()
76        self._updateform()
77
78    def _initcmd(self, ui):
79        # type: (uimod.ui) -> None
80        # TODO: forget old logs?
81        self._log_edit = cmdui.LogWidget(self)
82        self._qui.details_tabs.addTab(self._log_edit, _('Log'))
83        # as of hg 3.0, hgweb does not cooperate with command-server channel
84        self._agent = cmdcore.CmdAgent(ui, self, worker='proc')
85        self._agent.outputReceived.connect(self._log_edit.appendLog)
86        self._agent.busyChanged.connect(self._updateform)
87
88    def _initwebconf(self, webconf):
89        # type: (Optional[IniConfig]) -> None
90        self._webconf_form = WebconfForm(webconf=webconf, parent=self)
91        self._qui.details_tabs.addTab(self._webconf_form, _('Repositories'))
92
93    def _initactions(self):
94        # type: () -> None
95        self._qui.start_button.clicked.connect(self.start)
96        self._qui.stop_button.clicked.connect(self.stop)
97
98    @pyqtSlot()
99    def _updateform(self):
100        # type: () -> None
101        """update form availability and status text"""
102        self._updatestatus()
103        self._qui.start_button.setEnabled(not self.isstarted())
104        self._qui.stop_button.setEnabled(self.isstarted())
105        self._qui.settings_button.setEnabled(not self.isstarted())
106        self._qui.port_edit.setEnabled(not self.isstarted())
107        self._webconf_form.setEnabled(not self.isstarted())
108
109    def _updatestatus(self):
110        # type: () -> None
111        if self.isstarted():
112            # TODO: escape special chars
113            link = '<a href="%s">%s</a>' % (self.rooturl, self.rooturl)
114            msg = _('Running at %s') % link
115        else:
116            msg = _('Stopped')
117
118        self._qui.status_edit.setText(msg)
119
120    @pyqtSlot()
121    def start(self):
122        # type: () -> None
123        """Start web server"""
124        if self.isstarted():
125            return
126
127        self._agent.runCommand(self._cmdargs())
128
129    def _cmdargs(self):
130        # type: () -> List[Text]
131        """Build command args to run server"""
132        a = ['serve', '--port', str(self.port), '-v']
133        if self._singlerepo:
134            a += ['-R', self._singlerepo]
135        else:
136            a += ['--web-conf', self._tempwebconf()]
137        return a
138
139    def _tempwebconf(self):
140        # type: () -> Text
141        """Save current webconf to temporary file; return its path"""
142        if not hasattr(self._webconf, 'write'):
143            return hglib.tounicode(self._webconf.path)  # pytype: disable=attribute-error
144
145        assert isinstance(self._webconf, wconfig._wconfig)  # help pytype
146
147        fd, fname = tempfile.mkstemp(prefix=b'webconf_',
148                                     dir=qtlib.gettempdir())
149        f = os.fdopen(fd, 'w')
150        try:
151            self._webconf.write(f)
152            return hglib.tounicode(fname)
153        finally:
154            f.close()
155
156    @property
157    def _webconf(self):
158        # type: () -> IniConfig
159        """Selected webconf object"""
160        return self._webconf_form.webconf
161
162    @property
163    def _singlerepo(self):
164        # type: () -> Optional[Text]
165        """Return repository path if serving single repository"""
166        # TODO: The caller crashes if this returns None with:
167        #    `'ServeDialog' object has no attribute '_singlerepo'`
168        # NOTE: we cannot use web-conf to serve single repository at '/' path
169        if len(self._webconf[b'paths']) != 1:
170            return
171        path = self._webconf.get(b'paths', b'/')
172        if path and b'*' not in path:  # exactly a single repo (no wildcard)
173            return hglib.tounicode(path)
174
175    @pyqtSlot()
176    def stop(self):
177        # type: () -> None
178        """Stop web server"""
179        self._agent.abortCommands()
180
181    def reject(self):
182        # type: () -> None
183        self.stop()
184        super(ServeDialog, self).reject()
185
186    def isstarted(self):
187        # type: () -> bool
188        """Is the web server running?"""
189        return self._agent.isBusy()
190
191    @property
192    def rooturl(self):
193        # type: () -> Text
194        """Returns the root URL of the web server"""
195        # TODO: scheme, hostname ?
196        return 'http://localhost:%d' % self.port
197
198    @property
199    def port(self):
200        # type: () -> int
201        """Port number of the web server"""
202        return int(self._qui.port_edit.value())
203
204    def setport(self, port):
205        # type: (int) -> None
206        self._qui.port_edit.setValue(port)
207
208    def keyPressEvent(self, event):
209        if self.isstarted() and event.key() == Qt.Key_Escape:
210            self.stop()
211            return
212
213        return super(ServeDialog, self).keyPressEvent(event)
214
215    def closeEvent(self, event):
216        if self.isstarted():
217            self._minimizetotray()
218            event.ignore()
219            return
220
221        return super(ServeDialog, self).closeEvent(event)
222
223    @util.propertycache
224    def _trayicon(self):
225        icon = QSystemTrayIcon(self.windowIcon(), parent=self)
226        icon.activated.connect(self._restorefromtray)
227        icon.setToolTip(self.windowTitle())
228        # TODO: context menu
229        return icon
230
231    # TODO: minimize to tray by minimize button
232
233    @pyqtSlot()
234    def _minimizetotray(self):
235        self._trayicon.show()
236        self._trayicon.showMessage(_('TortoiseHg Web Server'),
237                                   _('Running at %s') % self.rooturl)
238        self.hide()
239
240    @pyqtSlot()
241    def _restorefromtray(self):
242        self._trayicon.hide()
243        self.show()
244
245    @pyqtSlot()
246    def on_settings_button_clicked(self):
247        from tortoisehg.hgqt import settings
248        settings.SettingsDialog(parent=self, focus='web.name').exec_()
249
250
251def _asconfigliststr(value):
252    # type: (bytes) -> bytes
253    r"""
254    >>> _asconfigliststr(b'foo')
255    b'foo'
256    >>> _asconfigliststr(b'foo bar')
257    b'"foo bar"'
258    >>> _asconfigliststr(b'foo,bar')
259    b'"foo,bar"'
260    >>> _asconfigliststr(b'foo "bar"')
261    b'"foo \\"bar\\""'
262    """
263    # ui.configlist() uses isspace(), which is locale-dependent
264    if any(c.isspace() or c == b',' for c in pycompat.iterbytestr(value)):
265        return b'"' + value.replace(b'"', b'\\"') + b'"'
266    else:
267        return value
268
269def _readconfig(ui, repopath, webconfpath):
270    # type: (uimod.ui, Optional[bytes], Optional[bytes]) -> Tuple[uimod.ui, Optional[IniConfig]]
271    """Create new ui and webconf object and read appropriate files"""
272    lui = ui.copy()
273    if webconfpath:
274        lui.readconfig(webconfpath)
275        # TODO: handle file not found
276        c = wconfig.readfile(webconfpath)
277        c.path = os.path.abspath(webconfpath)
278        return lui, c
279    elif repopath:  # imitate webconf for single repo
280        lui.readconfig(os.path.join(repopath, b'.hg', b'hgrc'), repopath)
281        c = wconfig.config()
282        try:
283            if not os.path.exists(os.path.join(repopath, b'.hgsub')):
284                # no _asconfigliststr(repopath) for now, because ServeDialog
285                # cannot parse it as a list in single-repo mode.
286                c.set(b'paths', b'/', repopath)
287            else:
288                # since hg 8cbb59124e67, path entry is parsed as a list
289                base = hglib.shortreponame(lui) or os.path.basename(repopath)
290                c.set(b'paths', base,
291                      _asconfigliststr(os.path.join(repopath, b'**')))
292        except (EnvironmentError, error.Abort, error.RepoError):
293            c.set(b'paths', b'/', repopath)
294        return lui, c
295    else:
296        return lui, None
297
298def run(ui, *pats, **opts):
299    # type: (uimod.ui, Any, Any) -> ServeDialog
300    # TODO: No known caller provides **opts so bytes vs str is unknown
301    repopath = opts.get('root') or paths.find_root_bytes()
302    webconfpath = opts.get('web_conf') or opts.get('webdir_conf')
303
304    lui, webconf = _readconfig(ui, repopath, webconfpath)
305    dlg = ServeDialog(lui, webconf=webconf)
306    try:
307        dlg.setport(int(lui.config(b'web', b'port')))
308    except ValueError:
309        pass
310
311    if repopath or webconfpath:
312        dlg.start()
313    return dlg
314