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