Restore Windows to macOS Spaces

This is a feature request. I’ve gotten so much out of TaskPaper that I hesitate to ask for more, but here goes:

After an upgrade, is there a way to have all of my previously-open windows re-open?

Thanks so much.

In the meanwhile here are a couple of draft scripts:

  1. Saving the current set of windows (positions and file paths) to a default JSON file
  2. Restoring a set of windows (positions and file paths) from that default JSON file.

Probably best launched (once tested, adjusted or adapted) by something like Keyboard Maestro, but can also be tested from Script Editor, if you set the language selector at top left to JavaScript

Save current TaskPaper windows
(() => {
    'use strict';

    // Save the current TaskPaper 3 window state to a JSON file.
    // A sibling script restores windows saved by this script.

    // Rob Trew 2020
    // Ver 0.01

    // main :: IO ()
    const main = () => {

        const fpWindowState = '~/Desktop/tpWindows.json';
        const
            tp = Application('TaskPaper'),
            ds = tp.documents;
        return either(msg => msg)(
            json => (
                writeFile(fpWindowState)(json),
                'Taskpaper window state saved to: ' + fpWindowState
            )
        )(
            bindLR(
                0 < ds.length ? (
                    Right(
                        zipWith(fp => xywh => ({
                            path: fp,
                            bounds: xywh
                        }))(
                            ds().map(d => d.file().toString())
                        )(
                            tp.windows().map(w => w.bounds())
                        )
                    )
                ) : Left('No TaskPaper documents open.')
            )(compose(Right, showJSON))
        );
    };


    // ---------------- GENERIC FUNCTIONS -----------------
    // https://github.com/RobTrew/prelude-jxa

    // Left :: a -> Either a b
    const Left = x => ({
        type: 'Either',
        Left: x
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: 'Either',
        Right: x
    });


    // Tuple (,) :: a -> b -> (a, b)
    const Tuple = a =>
        b => ({
            type: 'Tuple',
            '0': a,
            '1': b,
            length: 2
        });


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => undefined !== m.Left ? (
            m
        ) : mf(m.Right);


    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (...fs) =>
        fs.reduce(
            (f, g) => x => f(g(x)),
            x => x
        );


    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
        fr => e => 'Either' === e.type ? (
            undefined !== e.Left ? (
                fl(e.Left)
            ) : fr(e.Right)
        ) : undefined;


    // length :: [a] -> Int
    const length = xs =>
        // Returns Infinity over objects without finite
        // length. This enables zip and zipWith to choose
        // the shorter argument when one is non-finite,
        // like cycle, repeat etc
        'GeneratorFunction' !== xs.constructor.constructor.name ? (
            xs.length
        ) : Infinity;


    // list :: StringOrArrayLike b => b -> [a]
    const list = xs =>
        // xs itself, if it is an Array,
        // or an Array derived from xs.
        Array.isArray(xs) ? (
            xs
        ) : Array.from(xs);


    // showJSON :: a -> String
    const showJSON = x =>
        // Indented JSON representation of the value x.
        JSON.stringify(x, null, 2);


    // take :: Int -> [a] -> [a]
    // take :: Int -> String -> String
    const take = n =>
        // The first n elements of a list,
        // string of characters, or stream.
        xs => 'GeneratorFunction' !== xs
        .constructor.constructor.name ? (
            xs.slice(0, n)
        ) : [].concat.apply([], Array.from({
            length: n
        }, () => {
            const x = xs.next();
            return x.done ? [] : [x.value];
        }));


    // writeFile :: FilePath -> String -> IO ()
    const writeFile = fp => s =>
        $.NSString.alloc.initWithUTF8String(s)
        .writeToFileAtomicallyEncodingError(
            $(fp)
            .stringByStandardizingPath, false,
            $.NSUTF8StringEncoding, null
        );


    // zipWith:: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = f =>
        // A list constructed by zipping with a
        // custom function, rather than with the
        // default tuple constructor.
        xs => ys => ((xs_, ys_) => {
            const lng = Math.min(length(xs_), length(ys_));
            return take(lng)(xs_).map(
                (x, i) => f(x)(ys_[i])
            );
        })(list(xs), list(ys));

    // MAIN ---
    return main();
})();

Restore last save of TaskPaper windows
(() => {
    'use strict';

    // Reopen a set of saved windows in TaskPaper 3
    // File names and window positions saved in a JSON file
    // by a sibling script.

    // Rob Trew 2020

    // Ver 0.04 Generalised some of the functions.
    // Ver 0.03 ( simpler main() )
    // Ver 0.03 (Tidyied a bit for easier refactoring)
    // Ver 0.02 Added a timeout in case a window is not found.

    // main :: IO ()
    const main = () =>
        // Timeout in milliseconds, and
        // path of saved window states.
        windowStatesRestored(Application('TaskPaper'))(
            5000
        )('~/Desktop/tpWindows.json');


    // windowStatesRestored :: Int -> IO String
    const windowStatesRestored = app =>
        // TaskPaper windows restored rom JSON file
        // [{path : FilePath, bounds : Dict}] settings.
        timeout => fp => either(msg => msg)(fps => fps)(
            bindLR(
                doesFileExist(fp) ? (
                    jsonParseLR(readFile(fp))
                ) : Left('File not found: ' + fp)
            )(compose(
                Right,
                foldr(
                    pathOpenedWindowBoundsSet(app)(timeout)
                )('')
            ))
        );


    // pathOpenedWindowBoundsSet :: String ->
    // {path :: FilePath, bounds :: Dict} -> String -> String
    const pathOpenedWindowBoundsSet = app =>
        timeOut => dict => a => {
            const fp = dict.path;
            return (
                app.activate(),
                app.open(fp),
                a + either(msg => msg)(
                    w => (
                        w.bounds = dict.bounds,
                        fp
                    ),
                )(
                    appWindowForPathLR(app)(timeOut)(fp)
                ) + '\n'
            );
        };

    // appWindowForPathLR :: Int -> FilePath -> Either String Window
    const appWindowForPathLR = app =>
        msTimeout => fp => {
            const
                start = new Date(),
                fn = takeFileName(fp),
                lrNotSeen = Left('Window not found: ' + fn);
            return until(
                lr => Boolean(lr.Right) || (
                    msTimeout < (Date.now() - start)
                )
            )(lr => {
                const ws = app.windows;
                return (
                    delay(0.05),
                    ws.name().includes(fn) ? (
                        Right(ws.byName(fn))
                    ) : lrNotSeen
                );
            })(lrNotSeen)
        };


    // ---------------- GENERIC FUNCTIONS -----------------
    // https://github.com/RobTrew/prelude-jxa

    // Left :: a -> Either a b
    const Left = x => ({
        type: 'Either',
        Left: x
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: 'Either',
        Right: x
    });


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => undefined !== m.Left ? (
            m
        ) : mf(m.Right);


    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (...fs) =>
        fs.reduce(
            (f, g) => x => f(g(x)),
            x => x
        );


    // doesFileExist :: FilePath -> IO Bool
    const doesFileExist = fp => {
        const ref = Ref();
        return $.NSFileManager.defaultManager
            .fileExistsAtPathIsDirectory(
                $(fp)
                .stringByStandardizingPath, ref
            ) && 1 !== ref[0];
    };


    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
        fr => e => 'Either' === e.type ? (
            undefined !== e.Left ? (
                fl(e.Left)
            ) : fr(e.Right)
        ) : undefined;


    // foldr :: (a -> b -> b) -> b -> [a] -> b
    const foldr = f =>
        // Note that that the Haskell signature of foldr differs from that of
        // foldl - the positions of accumulator and current value are reversed
        a => xs => [...xs].reduceRight(
            (a, x) => f(x)(a),
            a
        );


    // jsonParseLR :: String -> Either String a
    const jsonParseLR = s => {
        try {
            return Right(JSON.parse(s));
        } catch (e) {
            return Left(`${e.message} (line:${e.line} col:${e.column})`);
        }
    };


    // readFile :: FilePath -> IO String
    const readFile = fp => {
        // The contents of a text file at the
        // path file fp.
        const
            e = $(),
            ns = $.NSString
            .stringWithContentsOfFileEncodingError(
                $(fp).stringByStandardizingPath,
                $.NSUTF8StringEncoding,
                e
            );
        return ObjC.unwrap(
            ns.isNil() ? (
                e.localizedDescription
            ) : ns
        );
    };


    // takeFileName :: FilePath -> FilePath
    const takeFileName = fp =>
        '' !== fp ? (
            '/' !== fp[fp.length - 1] ? (
                fp.split('/').slice(-1)[0]
            ) : ''
        ) : '';


    // until :: (a -> Bool) -> (a -> a) -> a -> a
    const until = p => f => x => {
        let v = x;
        while (!p(v)) v = f(v);
        return v;
    };


    // MAIN ---
    return main();
})();

1 Like

Thanks, that does the job!

Do you happen to know how to restore a window to a specific desktop number, by chance? I searched online quickly but I’m not sure I know what to look for and came up empty.

I suppose the first step would be to detect the desktop number for a given window, for subsequent restoration.

For some reason I don’t make much use of different desktops, so it’s not something I’ve looked at.

A first glance at Stack Overflow produced this:

Which suggests that plain osascript may need a little help, on current macOS versions.

No kidding! killall dock just to get the desktop number seems like a blunt instrument

There seems to be a discussion here of AppleScript routes to tying an application to a particular space. Not sure if its up to date.

And there’s this for the ObjC interface.

The picture seems to be that it’s not very readily scriptable.

Perhaps use GUI methods to lock TaskPaper to a particular space ?

I’ve got basically a TaskPaper window or two per desktop space. Desktop 1 has general TaskPaper windows. Desktop 6 has the TaskPaper window to go with the project I’m working on there, Desktop 8, 10, 12: same. I know. 12. It’s actually more like 16 desktop spaces in total, the max.

Yes, I can see the difficulty – Spaces seems to assume a unique map between an Application and a given Desktop.

Short of somehow having two versions of TaskPaper, one mapped to each of two spaces,
with two different window-state json files, I’m not quite sure how one would automate that.

Is this just a problem when upgrading? Not when quitting and restarting TaskPaper?

Yeah, it’d be nice either way, but upgrades are what cause me to quit TaskPaper usually.

Ok, I see problem now… you want them restored to specific spaces. And unfortunately this isn’t supported by default macOS “restore windows” functionally. I’m probably not going too fix this… much easier for me to just use default system behavior at this point.

I think this must be a general problem with many apps, not just TaskPaper? I did come across this tool that might also help:

https://cordlessdog.com/stay/

I’m going to change you thread title to “Restore Windows to macOS Spaces” … I think it’s a bit clearer for future searches… let me know if I’m misunderstood and that’s not an appropriate title.

I’d say I have two wishes (no expectations!):

  1. Restore previously-opened windows on TaskPaper open (if an upgrade was applied or chose to “Quit and Keep Windows” like Safari shows when pressing Option key in Safari menu).
  2. Restore previously-opened windows on TaskPaper open in the same macOS Spaces.

I can make do for #1 with @complexpoint’s suggested scripts, but #1 natively would solve the biggest problem for me.

Thanks for chiming in Jesse.

This is the part that I’m confused about… TaskPaper should already remember and restore your windows generally when you quit and then restart. (I’m not sure if “upgrade” factors into this) … but generally if you quite and then reopen TaskPaper do you see windows being restored?

If not there are tips in this thread (it can be effected by some system settings)

Oh my, you’re right.

So it’s mainly for those times when an upgrade is what quits TaskPaper and none of the windows are restored as it relaunches.