diff --git a/exporter/SynthesisFusionAddin/.gitignore b/exporter/SynthesisFusionAddin/.gitignore index e7ae5df513..9e33b772e7 100644 --- a/exporter/SynthesisFusionAddin/.gitignore +++ b/exporter/SynthesisFusionAddin/.gitignore @@ -108,4 +108,5 @@ site-packages **/.env proto/proto_out + .aps_auth diff --git a/exporter/SynthesisFusionAddin/Synthesis.py b/exporter/SynthesisFusionAddin/Synthesis.py index 335a2416ce..37403d9f82 100644 --- a/exporter/SynthesisFusionAddin/Synthesis.py +++ b/exporter/SynthesisFusionAddin/Synthesis.py @@ -1,15 +1,23 @@ -import importlib.util -import logging.handlers +import logging import os import traceback from shutil import rmtree import adsk.core +from .src.APS import APS from .src.configure import setAnalytics, unload_config from .src.general_imports import APP_NAME, DESCRIPTION, INTERNAL_ID, gm, root_logger from .src.Types.OString import OString -from .src.UI import HUI, Camera, ConfigCommand, Handlers, Helper, MarkingMenu +from .src.UI import ( + HUI, + Camera, + ConfigCommand, + Handlers, + Helper, + MarkingMenu, + ShowAPSAuthCommand, +) from .src.UI.Toolbar import Toolbar @@ -122,3 +130,14 @@ def register_ui() -> None: ) gm.elements.append(commandButton) + + apsButton = HUI.HButton( + "APS", + work_panel, + Helper.check_solid_open, + ShowAPSAuthCommand.ShowAPSAuthCommandCreatedHandler, + description=f"APS TEST", + command=True, + ) + + gm.elements.append(apsButton) diff --git a/exporter/SynthesisFusionAddin/src/APS/APS.py b/exporter/SynthesisFusionAddin/src/APS/APS.py new file mode 100644 index 0000000000..e31964e561 --- /dev/null +++ b/exporter/SynthesisFusionAddin/src/APS/APS.py @@ -0,0 +1,184 @@ +import json +import logging +import os +import pathlib +import pickle +import time +import urllib.parse +import urllib.request +from dataclasses import dataclass + +from ..general_imports import ( + APP_NAME, + DESCRIPTION, + INTERNAL_ID, + gm, + my_addin_path, + root_logger, +) + +CLIENT_ID = "GCxaewcLjsYlK8ud7Ka9AKf9dPwMR3e4GlybyfhAK2zvl3tU" +auth_path = os.path.abspath(os.path.join(my_addin_path, "..", ".aps_auth")) + +APS_AUTH = None +APS_USER_INFO = None + + +@dataclass +class APSAuth: + access_token: str + refresh_token: str + expires_in: int + expires_at: int + token_type: str + + +@dataclass +class APSUserInfo: + name: str + given_name: str + family_name: str + preferred_username: str + email: str + email_verified: bool + profile: str + locale: str + country_code: str + about_me: str + language: str + company: str + picture: str + + +def getAPSAuth() -> APSAuth | None: + return APS_AUTH + + +def _res_json(res): + return json.loads(res.read().decode(res.info().get_param("charset") or "utf-8")) + + +def getCodeChallenge() -> str | None: + endpoint = "http://localhost:80/api/aps/challenge/" + res = urllib.request.urlopen(endpoint) + data = _res_json(res) + return data["challenge"] + + +def getAuth() -> APSAuth: + global APS_AUTH + if APS_AUTH is not None: + return APS_AUTH + try: + with open(auth_path, "rb") as f: + p = pickle.load(f) + APS_AUTH = APSAuth( + access_token=p["access_token"], + refresh_token=p["refresh_token"], + expires_in=p["expires_in"], + expires_at=int(p["expires_in"] * 1000), + token_type=p["token_type"], + ) + except: + raise Exception("Need to sign in!") + curr_time = int(time.time() * 1000) + if curr_time >= APS_AUTH.expires_at: + refreshAuthToken() + if APS_USER_INFO is None: + loadUserInfo() + return APS_AUTH + + +def convertAuthToken(code: str): + global APS_AUTH + authUrl = f'http://localhost:80/api/aps/code/?code={code}&redirect_uri={urllib.parse.quote_plus("http://localhost:80/api/aps/exporter/")}' + res = urllib.request.urlopen(authUrl) + data = _res_json(res)["response"] + APS_AUTH = APSAuth( + access_token=data["access_token"], + refresh_token=data["refresh_token"], + expires_in=data["expires_in"], + expires_at=int(data["expires_in"] * 1000), + token_type=data["token_type"], + ) + with open(auth_path, "wb") as f: + pickle.dump(data, f) + f.close() + + loadUserInfo() + + +def removeAuth(): + global APS_AUTH, APS_USER_INFO + APS_AUTH = None + APS_USER_INFO = None + pathlib.Path.unlink(pathlib.Path(auth_path)) + + +def refreshAuthToken(): + global APS_AUTH + if APS_AUTH is None or APS_AUTH.refresh_token is None: + raise Exception("No refresh token found.") + body = urllib.parse.urlencode( + { + "client_id": CLIENT_ID, + "grant_type": "refresh_token", + "refresh_token": APS_AUTH.refresh_token, + "scope": "data:read", + } + ).encode("utf-8") + req = urllib.request.Request("https://developer.api.autodesk.com/authentication/v2/token", data=body) + req.method = "POST" + req.add_header(key="Content-Type", val="application/x-www-form-urlencoded") + try: + res = urllib.request.urlopen(req) + data = _res_json(res) + APS_AUTH = APSAuth( + access_token=data["access_token"], + refresh_token=data["refresh_token"], + expires_in=data["expires_in"], + expires_at=int(data["expires_in"] * 1000), + token_type=data["token_type"], + ) + except urllib.request.HTTPError as e: + removeAuth() + logging.getLogger(f"{INTERNAL_ID}").error(f"Refresh Error:\n{e.code} - {e.reason}") + gm.ui.messageBox("Please sign in again.") + + +def loadUserInfo() -> APSUserInfo | None: + global APS_AUTH + if not APS_AUTH: + return None + global APS_USER_INFO + req = urllib.request.Request("https://api.userprofile.autodesk.com/userinfo") + req.add_header(key="Authorization", val=APS_AUTH.access_token) + try: + res = urllib.request.urlopen(req) + data = _res_json(res) + APS_USER_INFO = APSUserInfo( + name=data["name"], + given_name=data["given_name"], + family_name=data["family_name"], + preferred_username=data["preferred_username"], + email=data["email"], + email_verified=data["email_verified"], + profile=data["profile"], + locale=data["locale"], + country_code=data["country_code"], + about_me=data["about_me"], + language=data["language"], + company=data["company"], + picture=data["picture"], + ) + return APS_USER_INFO + except urllib.request.HTTPError as e: + removeAuth() + logging.getLogger(f"{INTERNAL_ID}").error(f"User Info Error:\n{e.code} - {e.reason}") + gm.ui.messageBox("Please sign in again.") + + +def getUserInfo() -> APSUserInfo | None: + if APS_USER_INFO is not None: + return APS_USER_INFO + return loadUserInfo() diff --git a/exporter/SynthesisFusionAddin/src/Resources/APS/16x16-disabled.png b/exporter/SynthesisFusionAddin/src/Resources/APS/16x16-disabled.png new file mode 100644 index 0000000000..f4ba1b8f83 Binary files /dev/null and b/exporter/SynthesisFusionAddin/src/Resources/APS/16x16-disabled.png differ diff --git a/exporter/SynthesisFusionAddin/src/Resources/APS/16x16-normal.png b/exporter/SynthesisFusionAddin/src/Resources/APS/16x16-normal.png new file mode 100644 index 0000000000..34948454a9 Binary files /dev/null and b/exporter/SynthesisFusionAddin/src/Resources/APS/16x16-normal.png differ diff --git a/exporter/SynthesisFusionAddin/src/Resources/APS/32x32-disabled.png b/exporter/SynthesisFusionAddin/src/Resources/APS/32x32-disabled.png new file mode 100644 index 0000000000..770208ec0e Binary files /dev/null and b/exporter/SynthesisFusionAddin/src/Resources/APS/32x32-disabled.png differ diff --git a/exporter/SynthesisFusionAddin/src/Resources/APS/32x32-normal.png b/exporter/SynthesisFusionAddin/src/Resources/APS/32x32-normal.png new file mode 100644 index 0000000000..77458fce7d Binary files /dev/null and b/exporter/SynthesisFusionAddin/src/Resources/APS/32x32-normal.png differ diff --git a/exporter/SynthesisFusionAddin/src/Resources/APS/64x64-normal.png b/exporter/SynthesisFusionAddin/src/Resources/APS/64x64-normal.png new file mode 100644 index 0000000000..3076b744ca Binary files /dev/null and b/exporter/SynthesisFusionAddin/src/Resources/APS/64x64-normal.png differ diff --git a/exporter/SynthesisFusionAddin/src/UI/ConfigCommand.py b/exporter/SynthesisFusionAddin/src/UI/ConfigCommand.py index 6ea4e0b702..febb617ec4 100644 --- a/exporter/SynthesisFusionAddin/src/UI/ConfigCommand.py +++ b/exporter/SynthesisFusionAddin/src/UI/ConfigCommand.py @@ -12,6 +12,7 @@ import adsk.fusion from ..Analytics.alert import showAnalyticsAlert +from ..APS.APS import getAuth, getUserInfo, refreshAuthToken from ..configure import NOTIFIED, write_configuration from ..general_imports import * from ..Parser.ExporterOptions import ( @@ -779,6 +780,14 @@ def notify(self, args): # enabled=True, # ) + getAuth() + user_info = getUserInfo() + apsSettings = INPUTS_ROOT.addTabCommandInput( + "aps_settings", f"APS Settings ({user_info.given_name if user_info else 'Not Signed In'})" + ) + apsSettings.tooltip = "Configuration settings for Autodesk Platform Services." + aps_input = apsSettings.children + # clear all selections before instantiating handlers. gm.ui.activeSelections.clear() diff --git a/exporter/SynthesisFusionAddin/src/UI/ShowAPSAuthCommand.py b/exporter/SynthesisFusionAddin/src/UI/ShowAPSAuthCommand.py new file mode 100644 index 0000000000..bacc6e094b --- /dev/null +++ b/exporter/SynthesisFusionAddin/src/UI/ShowAPSAuthCommand.py @@ -0,0 +1,145 @@ +import json +import logging +import os +import time +import traceback +import urllib.parse +import urllib.request +import webbrowser + +import adsk.core + +from src.APS.APS import CLIENT_ID, auth_path, convertAuthToken, getCodeChallenge +from src.general_imports import ( + APP_NAME, + DESCRIPTION, + INTERNAL_ID, + gm, + my_addin_path, + root_logger, +) + +palette = None + + +class ShowAPSAuthCommandExecuteHandler(adsk.core.CommandEventHandler): + def __init__(self): + super().__init__() + + def notify(self, args): + try: + global palette + palette = gm.ui.palettes.itemById("authPalette") + if not palette: + callbackUrl = "http://localhost:80/api/aps/exporter/" + challenge = getCodeChallenge() + if challenge is None: + logging.getLogger(f"{INTERNAL_ID}").error( + "Code challenge is None when attempting to authorize for APS." + ) + return + params = { + "response_type": "code", + "client_id": CLIENT_ID, + "redirect_uri": urllib.parse.quote_plus(callbackUrl), + "scope": "data:read", + "nonce": time.time(), + "prompt": "login", + "code_challenge": challenge, + "code_challenge_method": "S256", + } + query = "&".join(map(lambda pair: f"{pair[0]}={pair[1]}", params.items())) + url = "https://developer.api.autodesk.com/authentication/v2/authorize?" + query + palette = gm.ui.palettes.add("authPalette", "APS Authentication", url, True, True, True, 400, 400) + palette.dockingState = adsk.core.PaletteDockingStates.PaletteDockStateRight + # register events + onHTMLEvent = MyHTMLEventHandler() + palette.incomingFromHTML.add(onHTMLEvent) + gm.handlers.append(onHTMLEvent) + + onClosed = MyCloseEventHandler() + palette.closed.add(onClosed) + gm.handlers.append(onClosed) + else: + palette.isVisible = True + except: + gm.ui.messageBox("Command executed failed: {}".format(traceback.format_exc())) + logging.getLogger(f"{INTERNAL_ID}").error("Command executed failed: {}".format(traceback.format_exc())) + if palette: + palette.deleteMe() + + +class ShowAPSAuthCommandCreatedHandler(adsk.core.CommandCreatedEventHandler): + def __init__(self, configure): + super().__init__() + + def notify(self, args): + try: + command = args.command + onExecute = ShowAPSAuthCommandExecuteHandler() + command.execute.add(onExecute) + gm.handlers.append(onExecute) + except: + gm.ui.messageBox("Failed:\n{}".format(traceback.format_exc())) + logging.getLogger(f"{INTERNAL_ID}").error("Failed:\n{}".format(traceback.format_exc())) + if palette: + palette.deleteMe() + + +class SendInfoCommandExecuteHandler(adsk.core.CommandEventHandler): + def __init__(self): + super().__init__() + + def notify(self, args): + pass + + +class SendInfoCommandCreatedHandler(adsk.core.CommandCreatedEventHandler): + def __init__(self): + super().__init__() + + def notify(self, args): + try: + command = args.command + onExecute = SendInfoCommandExecuteHandler() + command.execute.add(onExecute) + gm.handlers.append(onExecute) + except: + gm.ui.messageBox("Failed:\n{}".format(traceback.format_exc())) + logging.getLogger(f"{INTERNAL_ID}").error("Failed:\n{}".format(traceback.format_exc())) + if palette: + palette.deleteMe() + + +class MyCloseEventHandler(adsk.core.UserInterfaceGeneralEventHandler): + def __init__(self): + super().__init__() + + def notify(self, args): + try: + if palette: + palette.deleteMe() + # gm.ui.messageBox('Close button is clicked') + except: + gm.ui.messageBox("Failed:\n{}".format(traceback.format_exc())) + logging.getLogger(f"{INTERNAL_ID}").error("Failed:\n{}".format(traceback.format_exc())) + if palette: + palette.deleteMe() + + +class MyHTMLEventHandler(adsk.core.HTMLEventHandler): + def __init__(self): + super().__init__() + + def notify(self, args): + try: + htmlArgs = adsk.core.HTMLEventArgs.cast(args) + data = json.loads(htmlArgs.data) + # gm.ui.messageBox(msg) + + convertAuthToken(data["code"]) + except: + gm.ui.messageBox("Failed:\n".format(traceback.format_exc())) + logging.getLogger(f"{INTERNAL_ID}").error("Failed:\n".format(traceback.format_exc())) + if palette: + palette.deleteMe()