#!/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()