• TPO

    @shinyformica, I have the same issue and have come up with a solution based on a simple protocol. When applications follow the protocol, they will not "pile up on top of one another" when launched from the iOS home screen:

    • When an app is already active in Pythonista and it is launched again with its home screen shortcut, the new instance of the app detects the situation and exits, leaving the already active instance of the app on screen.
    • When an app is launched from its home screen shortcut and a previous app is already active in Pythonista, the previous app will be notified that it should terminate, its main UI view will be closed, and the new app will be launched.

    Protocol

    1. An application should create an instance of class AppSingleLaunch, and use it to test if the application is already active, using the is_active() method. If yes, the application should simply exit. If not, the application should declare its main UI view, using the will_present() method, and present the view. Here is an example:

       import app_single_launch
      
       app = AppSingleLaunch("MyApp")
       if not app.is_active():
           view = MyAppView(app)
           app.will_present(view)
           view.present()
      
    2. An application should make a call to the AppSingleLaunch.will_close() method, from the will_close() method of its main UI view:

       class MyAppView(ui.View):
      
           def __init__(self, app):
               self.app = app
      
           def will_close(self):
             self.app.will_close()
      

    Demo

    Save the code for app_single_launch.py and the two demo apps in the same directory.

    Define home screen shortcuts for the two demo apps.

    Launch demo app 1 using its home screen shortcut, type some text in the text field, then relaunch the app using its home screen shortcut : the text typed previously is still showing, meaning we are using the first launched instance, not the second one. Closing the app brings us back to the Pythonista IDE, not to previously piled up instances of the app.

    Launch demo app 1 using its home screen shortcut, then Launch demo app 2 using its home screen shortcut, then close it : the Pythonista IDE shows, not a piled up instance of demo app 1.

    Et voilà !

    app_single_launch.py

    """ Ensure a Pythonista script can only be launched once from the iOS home screen.
    
    This module provides a solution to the problem stated by shinyformica, which I
    have also encountered (https://forum.omz-software.com/topic/5440/prevent-
    duplicate-launch-from-shortcut):
        "Is there a good, canonical way to prevent a script from launching again if
        there is already an instance running? Specifically, I want to prevent the
        scenario where a user launches the script from the home screen via an app
        shortcut, then goes back to the home screen and launches it again.""
    
    The solution is based on a simple protocol, which applications need to adhere
    to. When this is the case, applications will not "pile up on top of one another"
    when launched from the iOS home screen:
    - When an app is already active in Pythonista and it is launched again with its
      home screen shortcut, the new instance of the app detects the situation and
      exits, leaving the already active instance of the app on screen.
    - When an app is launched from its home screen shortcut and a previous app is
      already active in Pythonista, the previous app will be notified that it
      should terminate, its main UI view will be closed, and the new app will be
      launched.
    
    Protocol:
    1) An application should create an instance of class AppSingleLaunch, and use
       it to test if the application is already active, using the is_active()
       method. If yes, the application should simply exit. If not, the application
       should declare its main UI view, using the will_present() method, and
       present the view. Here is an example:
    
            import app_single_launch
    
            app = AppSingleLaunch("MyApp")
            if not app.is_active():
                view = MyAppView(app)
                app.will_present(view)
                view.present()
    
    2) An application should make a call to the AppSingleLaunch.will_close()
       method, from the will_close() method of its main UI view:
    
            class MyAppView(ui.View):
    
                def __init__(self, app):
                    self.app = app
    
                def will_close(self):
                  self.app.will_close()
    
    Implementation: in order to achieve the desired result, we need to remember the
    last application launched according to the protocol, to determine if it is
    still active, and, if it is, to close it. This is achieved by storing into a
    lock file some information about the last application launched:
    - Its name, as passed to single_launch.launching()
    - The id of the ui.View instance for its main view, as passed to single_launch.
      launching(). This is later used to determine if the view is still on screen
      (when an object is still associated with the id), and to close the app's view.
      After several tests, it turns out we must use an ui.View object for this
      purpose, as they seem to persist better than other objects after the cleanup
      pykit-preflight.py does when an app is launched from the home screen.
    The location of the lock file is defined by global variable LOCK_PATH. The
    default location is in the 'site-packages' directory.
    
    Known issue:
    - When an app is on screen, then launched again from its home screen shortcut,
      some issues may happen with inline import statements (rare, would need to be
      qualified further).
    
    26-Feb-2019 TPO - Created this module
    28-Feb-2019 TPO - Initial release
     3-Mar-2019 TPO - Wrapped the code into the AppSingleLaunch class """
    
    
    import gc
    import json
    from pathlib import Path
    import time
    from typing import Any
    
    import ui
    
    
    __all__ = [
        'AppSingleLaunch',
    ]
    
    
    DEBUG = False
    LOCK_PATH = '~/Documents/site-packages/single_launch.lock'
    
    
    def _object_for_id(id_: int) -> Any:
        """ Return an object, given its id. """
    
        # Do a complete garbage collect, to avoid false positives in case the
        # object was still in use recently. In the context of AppSingleLaunch,
        # this would happen if an app was closed, then launched again immediately.
        gc.collect()
        for obj in gc.get_objects():
            if id(obj) == id_:
                return obj
        return None
    
    
    class AppSingleLaunch:
        """ Wrapper class for all module functionnality. """
    
        def __init__(self, app: str):
            """ Initialize an AppSingleLaunch instance.
    
            Arguments:
            - app: application name, which should be unique (but this is not
            enforced). """
            self.app = app
    
        def is_active(self) -> bool:
            """ Test if the application is already active.
    
            Returns:
            - True if the application is already running, in which case the caller
              should do nothing and exit.
            - False if the application is not already running, in which case the
              caller should launch the application in a normal way, and declare its
              main view by calling the will_present() method."""
            if DEBUG:
                print(f"is_active(), app = {self.app}")
            lock_path = Path(LOCK_PATH).expanduser()
            if lock_path.exists():
                with open(lock_path) as lock_file:
                    (lock_app, lock_view_id) = tuple(json.load(lock_file))
                lock_view = _object_for_id(lock_view_id)
                if DEBUG:
                    print("- Lock file =", lock_app, lock_view_id,
                          "valid" if lock_view else "invalid")
                if lock_app == self.app and lock_view:
                    if DEBUG:
                        print(f"- App {self.app} already active")
                    return True
            if DEBUG:
                print(f"- App {self.app} not active")
            return False
    
        def will_present(self, view: ui.View) -> None:
            """ Declare that the application is about to present its main view.
    
            Arguments:
            - view: ui.View instance for the app's main view. """
            if DEBUG:
                print(f"will_present({id(view)}), app = {self.app}")
            lock_path = Path(LOCK_PATH).expanduser()
            if lock_path.exists():
                with open(lock_path) as lock_file:
                    (lock_app, lock_view_id) = tuple(json.load(lock_file))
                lock_view = _object_for_id(lock_view_id)
                if DEBUG:
                    print("- Lock file =", lock_app, lock_view_id,
                          "valid" if lock_view else "invalid")
                if lock_app == self.app and lock_view:
                    raise ValueError(f"App {self.app} is already active, cannot "
                                     f"call will_present() against it.")
                else:
                    if lock_view and isinstance(lock_view, ui.View):
                        if DEBUG:
                            print(f"- Closing app {lock_app}")
                        lock_view.close()
                        time.sleep(1)  # Required for view to close properly
                    # else: lock is a leftover from a previous Pythonista session
                    #       and can be safely ignored.
            with open(lock_path, 'w') as lock_file:
                json.dump([self.app, id(view)], lock_file)
            if DEBUG:
                print(f"- Launching app {self.app}\n- Lock file =", self.app, id(view))
    
        def will_close(self) -> None:
            """ Declare that the application is about to close its main view. """
            lock_path = Path(LOCK_PATH).expanduser()
            if lock_path.exists():
                with open(lock_path) as lock_file:
                    (lock_app, lock_view_id) = tuple(json.load(lock_file))
                if lock_app != self.app:
                    raise ValueError(f"App {self.app} if not active, "
                                     f"{lock_app} is active")
                lock_path.unlink()
    

    single_launch_demo1.py

    """ app_single_launch demo script #1. """
    from app_single_launch import AppSingleLaunch
    import ui
    
    
    class MainView(ui.View):
        def __init__(self, app: AppSingleLaunch):
            self.app = app
            self.name = "Demo app 1"
            self.flex = 'WH'
            self.background_color = 'white'
            self.add_subview(ui.TextField(
                width=200,
                height=30,
                placeholder="Type some text"))
    
        def will_close(self) -> None:
            self.app.will_close()
    
    
    if __name__ == '__main__':
        app = AppSingleLaunch("Demo app 1")
        if not app.is_active():
            view = MainView(app)
            app.will_present(view)
            view.present()
    

    single_launch_demo2.py

    """ app_single_launch demo script #2. """
    from app_single_launch import AppSingleLaunch
    import ui
    
    
    class MainView(ui.View):
        def __init__(self, app: AppSingleLaunch):
            self.app = app
            self.name = "Demo app 2"
            self.flex = 'WH'
            self.background_color = 'white'
            self.add_subview(ui.TextField(
                width=200,
                height=30,
                placeholder="Type some text"))
    
        def will_close(self) -> None:
            self.app.will_close()
    
    
    if __name__ == '__main__':
        app = AppSingleLaunch("Demo app 2")
        if not app.is_active():
            view = MainView(app)
            app.will_present(view)
            view.present()
    

    posted in Pythonista read more
  • TPO

    @mikael you are quite right, dir() shows that safari_view_controler has a 'contentScrollView ' attribute.

    However, safari_view_controler.contentScrollView() returns None, both in reader_view() and in safariViewControllerDidFinish_() :-(

    I have gone through the official Apple doc on SFSafariViewController, 'contentScrollView ' is not mentionned anywhere.

    posted in Pythonista read more
  • TPO

    @mikael : works like a charm, thanks a lot !

    posted in Pythonista read more
  • TPO

    In addition to adopting cvp's SFSafariView solution, I have been experimenting with JonB's initial suggestion of using Safari's JS code for the reader mode.

    The reason for this is that SFSafariView is a black box which does not provide a way of getting / setting the position in the page. Being able to do this would allow a newsreader app to remember where the user stopped reading an article, and later re-open the article at this very position. This would be convenient for long articles which cannot be read in one sitting.

    If we had a way a generating a Safari-reader-mode-like filtered version of the web page, we could display it in a WebView and then get / set the position (as demonstrated by MarkdownView, https://github.com/mikaelho/pythonista-markdownview) to achieve this result.

    I have digged a bit into this, have found a more recent and more complete version of Safari's reader mode source code (both JS and CSS), and have had some success with it:

    • The web page is cleaned of most useless content
    • It is displayed in the mode selected (for example : sepia), with the font selected

    However:

    • Not all useless content is removed, and the result is not as good as what Safari produces
    • Margins and spacing between paragraphs are not handled properly

    You will find below my test code and pointers to the Safari sources, in case anyone is interested in investigating this further.

    Download the following files :

    Then run the following in the same directory :

    import wkwebview
    import ui
    
    with open('safari.js', 'r', encoding='utf-8') as f:
        safari = f.read()
    
    with open('reader-ui.css', 'r', encoding='utf-8') as f:
        reader_ui_css = f.read()
    
    with open('article-content.css', 'r', encoding='utf-8') as f:
        article_content_css = f.read()
    
    with open('theming.css', 'r', encoding='utf-8') as f:
        theming_css = f.read()
    
    js_script = f'''
        r = new ReaderArticleFinder(document);
        document.body.innerHTML = r.articleNode().innerHTML;
        document.body.classList="system sepia";
        var style = document.createElement('style');
        style.innerHTML = `{reader_ui_css}\n{article_content_css}\n{theming_css}`;
        var ref = document.querySelector('script');
        ref.parentNode.insertBefore(style, ref);
        document.getElementById("dynamic-article-content").sheet.insertRule("#article {{ font-size:15 px; line-height:25px; }}"); '''
    
    
    class Delegate:
    
        @ui.in_background
        def webview_did_finish_load(self, webview):
            w.eval_js(js_script)
    
    
    w = wkwebview.WKWebView(delegate=Delegate())
    w.present()
    w.add_script(safari)
    w.load_url('https://www.technologyreview.com/s/612929/wristwatch-heart-monitors-might-save-your-lifeand-change-medicine-too/')
    

    posted in Pythonista read more
  • TPO

    I have packaged cvp's solution into a module, which works like a charm, thanks a lot cvp ! (I use it for a newsreader).

    I have come across two limitations, though:

    • reader_view() cannot be used if 'uiview' has a NavigationView, or if any view presented below it has a NavigationView (even if the view has no relationship with 'uiview' - very strange)
    • No resizing is performed when the reader view changes orientation

    I am not very proficient with objc, would anyone know how to improve this ?

    """ Modal view which displays a web page in reader mode.
    
    Based on cvp's code:
    https://forum.omz-software.com/topic/5392/webview-and-reader-mode/18
    
    When run as a script, the module shows a small demo application.
    
    Implementation note:
    
        Transmitting the callback function from reader_view() to safariView-
        ControllerDidFinish_() turns out to be trickier than expected, especially
        when combining reader_view.py with app_single_launch.py.
    
        In this case, when the user launches the same app a second time, a new
        'safari_view_controler' instance seems to be created automagically. We
        therefore cannot store the callback function as one of the 'safari_view_
        controler's properties, since the instance created by reader_view() is
        silently replaced by a new instance when the app is relaunched, and
        ControllerDidFinish_() will receive the new instance, not the old one.
    
        In the end, the simplest solution works, i.e. storing the callback in a
        global. This does of course come with theoretical limitations: an applica-
        tion should not make a second call to reader_view() until the reader view
        displayed by the first call has been closed. This should not be too much of
        an issue, as I really cannot imagine a practical use case for doing this. I
        have included a simple protection against this edge case anyway.
    
    Revision history:
     6-Fev-2019 TPO - Initial release
    28-Fev-2019 TPO - Added the 'enter_reader_mode' argument to reader_view()
     7-Mar-2019 TPO - reader_view() now works when used with app_single_launch.py """
    
    
    from typing import Callable, Optional
    
    from objc_util import create_objc_class, nsurl, ObjCClass, ObjCInstance
    import ui
    
    
    __all__ = [
        'reader_view',
    ]
    
    
    Callback = Callable[[], None]
    __CALLBACK: Optional[Callback]
    __READER_VIEW_DISPLAYED = False
    
    
    def safariViewControllerDidFinish_(_self, _cmd, _controller):
        global __CALLBACK, __READER_VIEW_DISPLAYED
        if __CALLBACK:
            __CALLBACK()
        __READER_VIEW_DISPLAYED = False
    
    
    def reader_view(url: str,
                    uiview: ui.View,
                    callback: Optional[Callback] = None,
                    enter_reader_mode: bool = True) -> None:
        """ Modal view which displays a web page in reader mode.
    
        Arguments:
        - url: of the web page to be displayed. The only URL schemes allowed are
          http and https.  Any other URL scheme will raise an Objective-C exception
          and cause Pythonista to be terminated.
        - uiview: ui.View instance on which the web view is to be displayed. Note
          that reader_view() does not work if this is an instance of ui.Navigation-
          View.
        - callback: function of no argument, which is called when the user closes
          the view.
        - enter_reader_mode: if true and if reader mode is available, it will be
          entered automatically; if false, the user will have to tap the reader
          mode icon to enter reader mode (when available). """
        global __CALLBACK, __READER_VIEW_DISPLAYED
        if __READER_VIEW_DISPLAYED:
            raise ValueError("reader_view() already in use")
        __READER_VIEW_DISPLAYED = True
        __CALLBACK = callback
    
        MySFSafariViewControllerDelegate = create_objc_class(
            'MySFSafariViewControllerDelegate',
            methods=[safariViewControllerDidFinish_],
            protocols=['SFSafariViewControllerDelegate'])
        delegate = MySFSafariViewControllerDelegate.alloc().init()
        safari_view_controler_conf = (
            ObjCClass('SFSafariViewControllerConfiguration').alloc().init())
        safari_view_controler_conf.setEntersReaderIfAvailable_(enter_reader_mode)
        safari_view_controler = ObjCClass('SFSafariViewController').alloc() \
            .initWithURL_configuration_(nsurl(url), safari_view_controler_conf)
        safari_view_controler.delegate = delegate
        safari_view_controler.setModalPresentationStyle_(3)  # 3 = currentContext
        vc = ObjCClass('SUIViewController').viewControllerForView_(ObjCInstance(uiview))
        vc.presentViewController_animated_completion_(safari_view_controler, True, None)
    
    
    if __name__ == '__main__':
        from datetime import datetime
    
        def done():
            print("After user has closed reader view", datetime.now())
    
        def test(sender):
            print("Before call to reader_view()", datetime.now())
            reader_view(url=url, uiview=view, callback=done)
    
        url = 'http://www.nytimes.com/2019/01/29/climate/global-warming-extreme-weather.html'
        view = ui.View(
            flex='WH',
            background_color='white',
            name='reader_view demo',
            right_button_items=[ui.ButtonItem(title='test', action=test)])
        view.present()
    

    posted in Pythonista read more
  • TPO

    @cvp, it works, you're the greatest !!!

    posted in Pythonista read more
  • TPO

    @cvp, I am testing with app "MyLifeOrg 3", which does not publish an URL Scheme. Using your method, I get the following identifier : 'net.mylifeorganized.MLO2'. However, opening URL 'MLO2://' fails ("Safari cannot open the page, the address is not valid").

    Needless to say, the app is available as a target for the "Open app" action in Shortcuts...

    I have come across the following (https://talk.automators.fm/t/shortcuts-url-scheme/1940/3) : Shortcuts still supports the Workflow URL Scheme, which means one can use the following URL : 'workflow://run-workflow?name=Test' to open the app. (Where 'Test' is the name of the Shortcut script, which contains one action : 'OpenApp MyLifeOrg 3')

    However, this is clumsy, as :

    • First, Safari asks for confirmation ("Open this page in "Shortcuts" ?) : @JonB : you guessed right !
    • Second, the Shortcuts app appears briefly
    • Third, at last the target app is displayed

    Other suggestions and ideas on how to launch an arbitrary application, even if it does not publish an URL Scheme, are welcome !

    Edit : have also tried the "official" Shortcut URL Scheme, using the method described in https://talk.automators.fm/t/shortcuts-url-scheme/1940/2: shortcuts://x-callback-url/run-shortcut?name=Test&id=CD61E01D-22CF-43B8-966B-19AA134F0456&source=homescreen. The result is the same as with the old Workflow URL Scheme above.

    posted in Pythonista read more
  • TPO

    @cvp, both "Open app" and "Open in app" would be very useful to me, but I am afraid they will turn out to use Apple-restricted APIs :-(

    @mikael, indeed, this method used to work, but only for those app which publish an URL-scheme, and not all of them do. Plus, the method relied on app synchronisation via iTunes (to get access to the app's .ipa package), and that is now gone.

    A possible workaround would be to define a one-line Shortcut script for each app to be launched (action = "Open app", arg = "App"), and call this script from Pythonista. I was looking for something less clumsy ;)

    posted in Pythonista read more
  • TPO

    It used to be that only those apps which published an URL scheme could be launched from another app (and not all apps did so, meaning that some apps could simply not be launched from Pythonista).

    It would seem that things have changed with the advent of the Shortcut apps, which now has an 'Open app' action, whose list of apps seems to be quite exhaustive, i.e. even those apps which do not publish an URL scheme are available. (Shortcut's predecessor, Workflow, did not have this feature).

    Does anyone know if this functionnality can be used from Pythonista, and how ? Or this an Apple-only API that is reserved to Shortcuts ?

    posted in Pythonista read more

Internal error.

Oops! Looks like something went wrong!