Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
3-manifolds
GitHub Repository: 3-manifolds/Sage_macOS
Path: blob/main/Sage_framework/files/SageMath
173 views
#!/bin/sh
# Call the shell's exec command to run this script with python3. We
# actually call a symlink to Python3.9 named SageMath.  This is done
# so that the app name will be SageMath rather than python.
"exec" "`dirname $0`/../Frameworks/Sage.framework/Versions/Current/local/bin/SageMath" "$0" "$@"

import sys
import re
import os
from os.path import pardir, abspath, join as path_join
import subprocess
import signal
import json
import time
import plistlib
import tkinter
from tkinter import ttk
from tkinter.font import Font
from tkinter.simpledialog import Dialog
from tkinter.filedialog import askdirectory
from tkinter.messagebox import showerror, showwarning, askyesno
jupyter_id = re.compile('nbserver-([0-9]+)-open.html')
contents_dir = abspath(path_join(sys.argv[0], pardir, pardir))
framework_dir = path_join(contents_dir, 'Frameworks')
info_plist = path_join(contents_dir, 'Info.plist')
current = path_join(framework_dir, 'Sage.framework', 'Versions', 'Current')
sage_executable =  path_join(current, 'local', 'bin', 'sage')

def get_version():
    with open(info_plist, 'rb') as plist_file:
        info = plistlib.load(plist_file)
    return info['CFBundleShortVersionString']

sagemath_version = get_version()
jupyter_runtime_dir = path_join(os.environ['HOME'], 'Library', 'Application Support',
                                    'SageMath', sagemath_version, 'Jupyter')

class PopupMenu(ttk.Menubutton):
    def __init__(self, parent, variable, values):
        ttk.Menubutton.__init__(self, parent, textvariable=variable,
                                    direction='flush')
        self.parent = parent
        self.variable = variable
        self.update(values)

    def update(self, values):
        self.variable.set(values[0])
        self.menu = tkinter.Menu(self.parent, tearoff=False)
        for value in values:
            self.menu.add_radiobutton(label=value, variable=self.variable)
        self.config(menu=self.menu)

class Launcher:
    sage_cmd = 'clear ; %s ; exit'%sage_executable
    terminal_script = """
        set command to "%s"
        tell application "System Events"
            set terminalProcesses to application processes whose name is "Terminal"
        end tell
        if terminalProcesses is {} then
            set terminalIsRunning to false
        else
            set terminalIsRunning to true
        end if
        if terminalIsRunning then
            tell application "Terminal"
                activate
                do script command
            end tell
        else
        -- avoid opening two windows
        tell application "Terminal"
            activate
            do script command in window 1
            end tell
        end if
    """%sage_cmd

    iterm_script = """
        set sageCommand to "/bin/bash -c '%s'"
        tell application "iTerm"
            set sageWindow to (create window with default profile command sageCommand)
            select sageWindow
        end tell
    """%sage_cmd

    find_app_script = """
        set appExists to false
        try
	        tell application "Finder" to get application file id "%s"
            set appExists to true
        end try
        return appExists
    """

    def launch_terminal(self, app):
        if app == 'Terminal.app':
            subprocess.run(['osascript', '-'], input=self.terminal_script, text=True,
                               capture_output=True)
        elif app == 'iTerm.app':
            subprocess.run(['open', '-a', 'iTerm'], capture_output=True)
            subprocess.run(['osascript', '-'], input=self.iterm_script, text=True,
                               capture_output=True)
        return True

    def launch_notebook(self, url=None):
        environ = {'JUPYTER_RUNTIME_DIR': jupyter_runtime_dir}
        environ.update(os.environ)
        if url is None:
            if not self.check_notebook_dir():
                return False
            jupyter_notebook_dir = self.notebooks.get()
            if not jupyter_notebook_dir:
                jupyter_notebook_dir = os.environ['HOME']
            else:
                with open(self.nb_pref_file, 'w') as output:
                    output.write('%s\n'%jupyter_notebook_dir)
            subprocess.Popen([sage_executable, '--jupyter', 'notebook',
                     '--notebook-dir=%s'%jupyter_notebook_dir], env=environ)
        else:
            subprocess.run(['open', url], env=environ, capture_output=True)
        return True

    def find_app(self, bundle_id):
        script = self.find_app_script%bundle_id
        result = subprocess.run(['osascript', '-'], input=script, text=True,
                                    capture_output=True)
        return result.stdout.strip() == 'true' 

class LaunchWindow(tkinter.Toplevel, Launcher):
    def __init__(self, root):
        Launcher.__init__(self)
        self.nb_pref_file = path_join(jupyter_runtime_dir, 'notebook_dir')
        if os.path.exists(self.nb_pref_file):
            with open(self.nb_pref_file) as infile:
                notebook_dir = infile.read().strip()
        else:
            notebook_dir = ''
        self.root = root
        tkinter.Toplevel.__init__(self)
        self.tk.call('::tk::unsupported::MacWindowStyle', 'style', self._w,
                         'document', 'closeBox')
        self.protocol("WM_DELETE_WINDOW", self.quit)
        self.title('SageMath')
        self.columnconfigure(0, weight=1)
        frame = ttk.Frame(self, padding=10, width=300)
        frame.columnconfigure(0, weight=1)
        frame.grid(row=0, column=0, sticky=tkinter.NSEW)
        self.update_idletasks()
	# Logo
        resource_dir = abspath(path_join(sys.argv[0], pardir, pardir, 'Resources'))
        logo_file = path_join(resource_dir, 'sage_logo_256.png')
        try:
            self.logo_image = tkinter.PhotoImage(file=logo_file)
            logo = ttk.Label(frame, image=self.logo_image)
        except tkinter.TclError:
            logo = ttk.Label(frame, text='Logo Here')
	# Interfaces
        checks = ttk.Labelframe(frame, text="Available User Interfaces", padding=10)
        self.radio_var = radio_var = tkinter.Variable(checks, 'cli')
        self.use_cli = ttk.Radiobutton(checks, text="Command line", variable=radio_var,
            value='cli', command=self.update_radio_buttons)
        self.terminals = ['Terminal.app']
        if self.find_app('com.googlecode.iterm2'):
            self.terminals.append('iTerm.app')
        self.terminal_var = tkinter.Variable(self, self.terminals[0])
        self.terminal_option = PopupMenu(checks, self.terminal_var, self.terminals)
        self.use_jupyter = ttk.Radiobutton(checks, text="Jupyter notebook from folder:",
            variable=radio_var, value='nb',  command=self.update_radio_buttons)
        notebook_frame = ttk.Frame(checks)
        self.notebooks = ttk.Entry(notebook_frame, width=24)
        self.notebooks.insert(tkinter.END, notebook_dir)
        self.notebooks.config(state='readonly')
        self.browse = ttk.Button(notebook_frame, text='Select ...', padding=(-8, 0),
            command=self.browse_notebook_dir, state=tkinter.DISABLED)
        self.notebooks.grid(row=0, column=0)
        self.browse.grid(row=0, column=1)
	# Launch button
        self.launch = ttk.Button(frame, text="Launch", command=self.launch_sage)
    # Build the interfaces frame
        self.use_cli.grid(row=0, column=0, sticky=tkinter.W, pady=5)
        self.terminal_option.grid(row=1, column=0, sticky=tkinter.W, padx=10, pady=5)
        self.use_jupyter.grid(row=2, column=0, sticky=tkinter.W, pady=5)
        notebook_frame.grid(row=3, column=0, sticky=tkinter.W, pady=5)
	# Build the window
        logo.grid(row=0, column=0, pady=5)
        checks.grid(row=1, column=0, padx=10, pady=10, sticky=tkinter.EW)
        self.launch.grid(row=2, column=0)
        self.geometry('380x350+400+400')
        
    def quit(self):
        self.destroy()
        self.root.destroy()

    def update_radio_buttons(self):
        radio = self.radio_var.get()
        if radio == 'cli':
            self.notebooks.config(state=tkinter.DISABLED)
            self.browse.config(state=tkinter.DISABLED)
            self.terminal_option.config(state=tkinter.NORMAL)
        elif radio == 'nb':
            self.notebooks.config(state='readonly')
            self.browse.config(state=tkinter.NORMAL)
            self.terminal_option.config(state=tkinter.DISABLED)
        
    def launch_sage(self):
        interface = self.radio_var.get()
        if interface == 'cli':
            launched = self.launch_terminal(app=self.terminal_var.get())
        elif interface == 'nb':
            jupyter_openers = [f for f in os.listdir(jupyter_runtime_dir)
                                   if f[-4:] == 'html']
            if not jupyter_openers:
                launched = self.launch_notebook(None)
            else:
                html_file = path_join(jupyter_runtime_dir, jupyter_openers[0]) 
                launched = self.launch_notebook(html_file)
        if launched:
            self.quit()

    def check_notebook_dir(self):
        notebook_dir = self.notebooks.get()
        if not notebook_dir.strip():
            showwarning(parent=self,
                message="Please choose or create a folder for your Jupyter notebooks.")
            return False
        if not os.path.exists(notebook_dir):
            answer = askyesno(message='May we create the folder %s?'%notebook_dir)
            if answer == tkinter.YES:
                os.makedirs(notebook_dir, exist_ok=True)
            else:
                return False
        try:
            os.listdir(notebook_dir)
        except:
            showerror(message='Sorry. We do not have permission to read %s'%directory)
            return False
        return True
            
    def browse_notebook_dir(self):
        json_files = [filename for filename in os.listdir(jupyter_runtime_dir)
                          if os.path.splitext(filename)[1] == '.json']
        if json_files:
            answer = askyesno(message='You already have a Jupyter server running with '
                                  'the notebook directory shown.  Do you want to stop '
                                  'that server and start a new one?')
            if answer == tkinter.YES:
                for json_file in json_files:
                    with open(os.path.join(jupyter_runtime_dir, json_file)) as in_file:
                        try:
                            pid = int(json.load(in_file)['pid'])
                            os.kill(pid, signal.SIGINT)
                            time.sleep(2)
                            os.kill(pid, signal.SIGINT)
                        except:
                            pass
            else:
                return
        directory = askdirectory(parent=self, initialdir=os.environ['HOME'],
            message='Choose or create a folder for Jupyter notebooks')
        if directory:
            self.notebooks.config(state=tkinter.NORMAL)
            self.notebooks.delete(0, tkinter.END)
            self.notebooks.insert(tkinter.END, directory)
            self.notebooks.config(state='readonly')
            
class AboutDialog(Dialog):
    def __init__(self, master, title='', content=''):
        self.content = content
        self.style = ttk.Style(master)
        resource_dir = abspath(path_join(sys.argv[0], pardir, pardir, 'Resources'))
        logo_file = path_join(resource_dir, 'sage_logo_256.png')
        try:
            self.logo_image = tkinter.PhotoImage(file=logo_file)
        except tkinter.TclError:
            self.logo_image = None
        Dialog.__init__(self, master, title=title)
        
    def body(self, master):
        self.resizable(False, False)
        frame = ttk.Frame(self)
        if self.logo_image:
            logo = ttk.Label(frame, image=self.logo_image)
        else:
            logo = ttk.Label(frame, text='Logo Here')
        logo.grid(row=0, column=0, padx=20, pady=20, sticky=tkinter.N)
        message = tkinter.Message(frame, text=self.content)
        message.grid(row=1, column=0, padx=20, sticky=tkinter.EW)
        frame.pack()

    def buttonbox(self):
        frame = ttk.Frame(self, padding=(0, 0, 0, 20))
        ok = ttk.Button(frame, text="OK", width=10, command=self.ok,
                            default=tkinter.ACTIVE)
        ok.grid(row=2, column=0, padx=5, pady=5)
        self.bind("<Return>", self.ok)
        self.bind("<Escape>", self.ok)
        frame.pack()

class SageApp(Launcher):
    resource_dir = abspath(path_join(sys.argv[0], pardir, pardir, 'Resources'))
    icon_file = abspath(path_join(resource_dir, 'sage_icon_1024.png'))
    about = """
SageMath is a free open-source mathematics software system licensed under the GPL. Please visit sagemath.org for more information about SageMath.

This SageMath app contains a subset of the SageMath binary distribution available from sagemath.org. It is packaged as a component of the 3-manifolds project by Marc Culler, Nathan Dunfield, and Matthias Gӧrner.  It is licensed under the GPL License, version 2 or later, and can be downloaded from
https://github.com/3-manifolds/Sage_macOS/releases.

The app is copyright © 2021 by Marc Culler, Nathan Dunfield, Matthias Gӧrner and others.
"""

    def __init__(self):
        self.root_window = root = tkinter.Tk()
        root.withdraw()
        os.chdir(os.environ['HOME'])
        os.makedirs(jupyter_runtime_dir, mode=0o755, exist_ok=True)
        self.icon = tkinter.Image("photo", file=self.icon_file)
        root.tk.call('wm','iconphoto', root._w, self.icon)
        self.menubar = menubar = tkinter.Menu(root)
        apple_menu = tkinter.Menu(menubar, name="apple")
        apple_menu.add_command(label='About SageMath ...', command=self.about_sagemath)
        menubar.add_cascade(menu=apple_menu)
        root.config(menu=menubar)
        ttk.Label(root, text="SageMath 9.3").pack(padx=20, pady=20)

    def about_sagemath(self):
        AboutDialog(self.root_window, 'SageMath', self.about)

    def run(self):
        symlink = path_join(os.path.sep, 'var', 'tmp', 'sage-%s-current'%sagemath_version)
        self.launcher = LaunchWindow(root=self.root_window)
        if not os.path.islink(symlink):
            try:
                os.symlink(current, symlink)
            except Exception as e:
                showwarning(parent=self.root_window,
                            message="%s Cannot create %s; SageMath must exit."%(e, symlink))
                sys.exit(1)
        self.root_window.mainloop()
        
if __name__ == '__main__':
    SageApp().run()