diff --git a/lib/extensions/print_pdf.py b/lib/extensions/print_pdf.py
index c3c14e489..6ee051a1d 100644
--- a/lib/extensions/print_pdf.py
+++ b/lib/extensions/print_pdf.py
@@ -9,25 +9,27 @@ import os
 import socket
 import sys
 import time
+import webbrowser
+from contextlib import closing
 from copy import deepcopy
 from datetime import date
 from threading import Thread
-from contextlib import closing
 
 import appdirs
+import wx
 from flask import Flask, Response, jsonify, request, send_from_directory
 from jinja2 import Environment, FileSystemLoader, select_autoescape
 from lxml import etree
 from werkzeug.serving import make_server
 
-from ..gui import open_url
-from ..i18n import get_languages
+from .base import InkstitchExtension
+from ..debug import debug
+from ..i18n import _, get_languages
 from ..i18n import translation as inkstitch_translation
 from ..stitch_plan import stitch_groups_to_stitch_plan
 from ..svg import render_stitch_plan
 from ..svg.tags import INKSCAPE_GROUPMODE
 from ..threads import ThreadCatalog
-from .base import InkstitchExtension
 
 
 def datetimeformat(value, format='%Y/%m/%d'):
@@ -57,6 +59,42 @@ def save_defaults(defaults):
         json.dump(defaults, defaults_file)
 
 
+def open_url(url):
+    # Avoid spurious output from xdg-open.  Any output on stdout will crash
+    # inkscape.
+    null = open(os.devnull, 'w')
+    old_stdout = os.dup(sys.stdout.fileno())
+    os.dup2(null.fileno(), sys.stdout.fileno())
+
+    if getattr(sys, 'frozen', False):
+
+        # PyInstaller sets LD_LIBRARY_PATH.  We need to temporarily clear it
+        # to avoid confusing xdg-open, which webbrowser will run.
+
+        # The following code is adapted from PyInstaller's documentation
+        # http://pyinstaller.readthedocs.io/en/stable/runtime-information.html
+
+        old_environ = dict(os.environ)  # make a copy of the environment
+        lp_key = 'LD_LIBRARY_PATH'  # for Linux and *BSD.
+        lp_orig = os.environ.get(lp_key + '_ORIG')  # pyinstaller >= 20160820 has this
+        if lp_orig is not None:
+            os.environ[lp_key] = lp_orig  # restore the original, unmodified value
+        else:
+            os.environ.pop(lp_key, None)  # last resort: remove the env var
+
+        webbrowser.open(url)
+
+        # restore the old environ
+        os.environ.clear()
+        os.environ.update(old_environ)
+    else:
+        webbrowser.open(url)
+
+    # restore file descriptors
+    os.dup2(old_stdout, sys.stdout.fileno())
+    os.close(old_stdout)
+
+
 class PrintPreviewServer(Thread):
     def __init__(self, *args, **kwargs):
         self.html = kwargs.pop('html')
@@ -66,8 +104,11 @@ class PrintPreviewServer(Thread):
         self.realistic_color_block_svgs = kwargs.pop('realistic_color_block_svgs')
         Thread.__init__(self, *args, **kwargs)
         self.daemon = True
+        self.last_request_time = None
+        self.shutting_down = False
         self.flask_server = None
         self.server_thread = None
+        self.started = False
 
         self.__setup_app()
 
@@ -89,14 +130,33 @@ class PrintPreviewServer(Thread):
 
         self.app = Flask(__name__)
 
+        self.watcher_thread = Thread(target=self.watch)
+        self.watcher_thread.daemon = True
+        self.watcher_thread.start()
+
+        @self.app.before_request
+        def request_started():
+            self.last_request_time = time.time()
+
         @self.app.route('/')
         def index():
             return self.html
 
+        @self.app.route('/shutdown', methods=['POST'])
+        def shutdown():
+            self.shutting_down = True
+            return _('Closing...') + '<br/><br/>' + _('It is safe to close this window now.')
+
         @self.app.route('/resources/<path:resource>', methods=['GET'])
         def resources(resource):
             return send_from_directory(self.resources_path, resource, max_age=1)
 
+        @self.app.route('/ping')
+        def ping():
+            debug.log("got a ping")
+            # Javascript is letting us know it's still there.  This resets self.last_request_time.
+            return "pong"
+
         @self.app.route('/settings/<field_name>', methods=['POST'])
         def set_field(field_name):
             self.metadata[field_name] = request.json['value']
@@ -155,10 +215,40 @@ class PrintPreviewServer(Thread):
         def get_realistic_overview():
             return Response(self.realistic_overview_svg, mimetype='image/svg+xml')
 
+        @self.app.route('/printing/start')
+        def printing_start():
+            # temporarily turn off the watcher while the print dialog is up,
+            # because javascript will be frozen
+            self.last_request_time = None
+            return "OK"
+
+        @self.app.route('/printing/end')
+        def printing_end():
+            # nothing to do here -- request_started() will restart the watcher
+            return "OK"
+
     def stop(self):
         self.flask_server.shutdown()
         self.server_thread.join()
 
+    def watch(self):
+        try:
+            while True:
+                time.sleep(1)
+                if self.shutting_down:
+                    debug.log("watcher thread: shutting down")
+                    self.stop()
+                    break
+
+                if self.last_request_time is not None and (time.time() - self.last_request_time) > 3:
+                    debug.log("watcher thread: timed out, stopping")
+                    self.stop()
+                    break
+        except BaseException:
+            # seems like sometimes this thread blows up during shutdown
+            debug.log(f"exception in watcher {sys.exc_info()}")
+            pass
+
     def disable_logging(self):
         logging.getLogger('werkzeug').setLevel(logging.ERROR)
 
@@ -180,6 +270,52 @@ class PrintPreviewServer(Thread):
         self.flask_server = make_server(self.host, self.port, self.app)
         self.server_thread = Thread(target=self.flask_server.serve_forever)
         self.server_thread.start()
+        self.started = True
+        self.server_thread.join()
+
+
+class PrintInfoFrame(wx.Frame):
+    def __init__(self, *args, **kwargs):
+        self.print_server = kwargs.pop("print_server")
+        wx.Frame.__init__(self, *args, **kwargs)
+
+        panel = wx.Panel(self)
+        sizer = wx.BoxSizer(wx.VERTICAL)
+
+        self.message = _(
+            "A print preview has been opened in your web browser.  "
+            "This window will stay open in order to communicate with the JavaScript code running in your browser.\n\n"
+            "This window will close after you close the print preview in your browser, or you can close it manually if necessary."
+        )
+        self.text = wx.StaticText(panel, label=self.message)
+        font = wx.Font(14, wx.DEFAULT, wx.NORMAL, wx.NORMAL)
+        self.text.SetFont(font)
+        self.Bind(wx.EVT_SIZE, self.resize)
+        sizer.Add(self.text, proportion=1, flag=wx.ALL | wx.EXPAND, border=20)
+
+        stop_button = wx.Button(panel, id=wx.ID_CLOSE)
+        stop_button.Bind(wx.EVT_BUTTON, self.close_button_clicked)
+        sizer.Add(stop_button, proportion=0, flag=wx.ALIGN_CENTER | wx.ALL, border=10)
+
+        panel.SetSizer(sizer)
+        panel.Layout()
+
+        self.timer = wx.PyTimer(self.__watcher)
+        self.timer.Start(250)
+
+    def resize(self, event=None):
+        self.text.SetLabel(self.message)
+        self.text.Wrap(self.GetSize().width - 35)
+        self.Layout()
+
+    def close_button_clicked(self, event):
+        self.print_server.stop()
+
+    def __watcher(self):
+        if self.print_server.started and not self.print_server.is_alive():
+            self.timer.Stop()
+            self.timer = None
+            self.Destroy()
 
 
 class Print(InkstitchExtension):
@@ -330,12 +466,11 @@ class Print(InkstitchExtension):
             realistic_color_block_svgs=realistic_color_block_svgs
         )
         print_server.start()
-        # Wait for print_server.host and print_server.port to be populated.
-        # Hacky, but Flask doesn't have an option for a callback to be run
-        # after startup.
-        time.sleep(0.5)
 
-        browser_window = open_url(print_server.host, print_server.port, True)
-        browser_window.wait()
-        print_server.stop()
-        print_server.join()
+        time.sleep(1)
+        open_url("http://%s:%s/" % (print_server.host, print_server.port))
+
+        app = wx.App()
+        info_frame = PrintInfoFrame(None, title=_("Ink/Stitch Print"), size=(450, 350), print_server=print_server)
+        info_frame.Show()
+        app.MainLoop()
diff --git a/print/resources/inkstitch.js b/print/resources/inkstitch.js
index 382ced3bd..540ed7f0a 100644
--- a/print/resources/inkstitch.js
+++ b/print/resources/inkstitch.js
@@ -11,6 +11,12 @@ var realistic_rendering = {};
 var realistic_cache = {};
 var normal_rendering = {};
 
+function ping() {
+  $.get("/ping")
+   .done(function() { setTimeout(ping, 1000) })
+   .fail(function() { $('#errors').attr('class', 'show') });
+}
+
 //function to chunk opd view into pieces 
   // source: https://stackoverflow.com/questions/3366529/wrap-every-3-divs-in-a-div
 $.fn.chunk = function(size) {
@@ -203,6 +209,7 @@ function setSVGTransform(figure, transform) {
 }
 
 $(function() {
+  setTimeout(ping, 1000);
   /* SCALING AND MOVING SVG  */
 
   /* Mousewheel scaling */
@@ -372,20 +379,35 @@ $(function() {
   /* Settings Bar */
 
   $('button.close').click(function() {
-     window.close();
+    $.post('/shutdown', {})
+     .always(function(data) {
+       window.close();
+
+       /* Chrome and Firefox both have a rule: scripts can only close windows
+        * that they opened.  Chrome seems to have an exception for windows that
+        * were opened by an outside program, so the above works fine.  Firefox
+        * steadfastly refuses to allow us to close the window, so we'll tell
+        * the user (in their language) that they can close it.
+        */
+       setTimeout(function() {
+           document.open();
+           document.write("<html><body>" + data + "</body></html>");
+           document.close();
+       }, 1000);
+    });
   });
 
   $('button.print').click(function() {
-    var pageSize = $('select#printing-size').find(':selected').text();
-    window.inkstitchAPI.openpdf(pageSize)
+    // printing halts all javascript activity, so we need to tell the backend
+    // not to shut down until we're done.
+    $.get("/printing/start")
+     .done(function() {
+        window.print();
+        $.get("/printing/end");
+     });
   });
 
-  $('button.save-pdf').click(function() {
-    var pageSize = $('select#printing-size').find(':selected').text();
-    window.inkstitchAPI.savepdf(pageSize)
-    });
-  
-  $('button.settings').click(function(){
+$('button.settings').click(function(){
     $('#settings-ui').show();
   });
 
diff --git a/print/resources/style.css b/print/resources/style.css
index db5ba603f..a32007444 100644
--- a/print/resources/style.css
+++ b/print/resources/style.css
@@ -125,7 +125,7 @@ body {
     color: white;
   }
 
-  .ui button.print, .ui button.save-pdf {
+  .ui button.print {
     border: 1px solid rgb(50,132,50);
   }
 
diff --git a/print/templates/ui.html b/print/templates/ui.html
index 4282838d3..846b7b497 100644
--- a/print/templates/ui.html
+++ b/print/templates/ui.html
@@ -2,7 +2,6 @@
         <p class="header">{{ _('Ink/Stitch Print Preview') }}</p>
         <div class="buttons">
           <button class="print">{{ _('Print') }}</button>
-          <button class="save-pdf">{{ _('Save PDF') }}</button>
           <button class="settings">{{ _('Settings') }}</button>
           <button class="close">{{ _('Close') }}</button>
         </div>