From f9432975362ac321a558feff1a97fb0f39d0add4 Mon Sep 17 00:00:00 2001 From: Mik Mueller <83001409+MikMuellerDev@users.noreply.github.com> Date: Tue, 27 Jun 2023 22:33:38 +0200 Subject: [PATCH] feat: Update to Homescript v3 --- core/homescript/analyzerBuiltin.go | 360 +++++++++ core/homescript/analyzerExecutor.go | 265 ------ core/homescript/automationRunner.go | 84 +- core/homescript/builtin.go | 669 ---------------- core/homescript/executor.go | 754 +----------------- core/homescript/homescript_test.go | 2 +- core/homescript/interpreterBuiltin.go | 642 +++++++++++++++ core/homescript/manager.go | 548 +++++++------ core/homescript/scheduleRunner.go | 69 +- core/shutdown.go | 6 +- server/api/homescript.go | 167 ++-- server/api/homescriptAsync.go | 54 +- web/package-lock.json | 281 +++---- web/package.json | 2 +- .../ExecutionResultPopup/Terminal.svelte | 90 ++- .../Homescript/HmsEditor/HmsEditor.svelte | 65 +- .../Homescript/HmsEditor/oneDark.ts | 1 + web/src/homescript.ts | 22 +- web/src/pages/dash/App.svelte | 9 +- web/src/pages/hmsEditor/App.svelte | 55 +- web/src/pages/hmsEditor/websocket.ts | 1 - 21 files changed, 1759 insertions(+), 2387 deletions(-) create mode 100644 core/homescript/analyzerBuiltin.go delete mode 100644 core/homescript/analyzerExecutor.go delete mode 100644 core/homescript/builtin.go create mode 100644 core/homescript/interpreterBuiltin.go diff --git a/core/homescript/analyzerBuiltin.go b/core/homescript/analyzerBuiltin.go new file mode 100644 index 00000000..65f26822 --- /dev/null +++ b/core/homescript/analyzerBuiltin.go @@ -0,0 +1,360 @@ +package homescript + +import ( + "fmt" + + "github.com/smarthome-go/homescript/v3/homescript/analyzer" + "github.com/smarthome-go/homescript/v3/homescript/analyzer/ast" + "github.com/smarthome-go/homescript/v3/homescript/errors" + pAst "github.com/smarthome-go/homescript/v3/homescript/parser/ast" + "github.com/smarthome-go/smarthome/core/database" +) + +type analyzerHost struct { + username string +} + +func newAnalyzerHost(username string) analyzerHost { + return analyzerHost{ + username: username, + } +} + +func (self analyzerHost) GetBuiltinImport(moduleName string, valueName string, span errors.Span) (valueType ast.Type, moduleFound bool, valueFound bool) { + switch moduleName { + case "switch": + switch valueName { + case "power": + return ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("switch_id", span), ast.NewStringType(span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("power", span), ast.NewBoolType(span)), + }), + span, + ast.NewNullType(span), + span, + ), true, true + default: + return nil, true, false + } + case "widget": + switch valueName { + case "on_click_js", "on_click_hms": + return ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("base", span), ast.NewStringType(span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("js", span), ast.NewStringType(span)), + }), + span, ast.NewStringType(span), span, + ), true, true + default: + return nil, true, false + } + case "testing": + switch valueName { + case "assert_eq": + return ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("lhs", errors.Span{}), ast.NewUnknownType()), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("rhs", errors.Span{}), ast.NewUnknownType()), + }), + errors.Span{}, + ast.NewNullType(errors.Span{}), + errors.Span{}, + ), true, true + default: + return nil, true, false + } + case "storage": + switch valueName { + case "set_storage": + return ast.NewFunctionType(ast.NewNormalFunctionTypeParamKind( + []ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("key", span), ast.NewStringType(span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("value", span), ast.NewUnknownType()), + }, + ), + span, + ast.NewNullType(span), + span, + ), true, true + case "get_storage": + return ast.NewFunctionType(ast.NewNormalFunctionTypeParamKind( + []ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("key", span), ast.NewStringType(span)), + }, + ), + span, + ast.NewObjectType([]ast.ObjectTypeField{ + ast.NewObjectTypeField(pAst.NewSpannedIdent("value", span), ast.NewStringType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("found", span), ast.NewBoolType(span), span), + }, + span), + span, + ), true, true + default: + return nil, true, false + } + case "reminder": + switch valueName { + case "remind": + return ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("reminder", span), + ast.NewObjectType([]ast.ObjectTypeField{ + ast.NewObjectTypeField(pAst.NewSpannedIdent("title", span), ast.NewStringType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("description", span), ast.NewStringType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("priority", span), ast.NewIntType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("due_date_day", span), ast.NewIntType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("due_date_month", span), ast.NewIntType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("due_date_year", span), ast.NewIntType(span), span), + }, span)), + }), + span, + ast.NewIntType(span), + span, + ), true, true + default: + return nil, true, false + } + case "net": + newHttpResponse := func() ast.Type { + return ast.NewObjectType( + []ast.ObjectTypeField{ + ast.NewObjectTypeField(pAst.NewSpannedIdent("status", span), ast.NewStringType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("status_code", span), ast.NewIntType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("body", span), ast.NewStringType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("cookies", span), ast.NewAnyObjectType(span), span), + }, + span, + ) + } + + switch valueName { + case "ping": + return ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("ip", span), ast.NewStringType(span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("timeout", span), ast.NewFloatType(span)), + }), + span, + ast.NewBoolType(span), + span, + ), true, true + case "HttpResponse": + return newHttpResponse(), true, true + case "http": + return ast.NewObjectType([]ast.ObjectTypeField{ + ast.NewObjectTypeField(pAst.NewSpannedIdent("get", span), ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ast.NewFunctionTypeParam(pAst.NewSpannedIdent("url", span), ast.NewStringType(span))}), + span, + newHttpResponse(), + span, + ), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("generic", span), ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind( + []ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("url", span), ast.NewStringType(span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("method", span), ast.NewStringType(span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("body", span), ast.NewOptionType(ast.NewStringType(span), span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("headers", span), ast.NewAnyObjectType(span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("cookies", span), ast.NewAnyObjectType(span)), + }, + ), + span, + newHttpResponse(), + span, + ), span), + }, span), true, true + default: + return nil, true, false + } + case "log": + switch valueName { + case "logger": + return ast.NewObjectType([]ast.ObjectTypeField{ + ast.NewObjectTypeField(pAst.NewSpannedIdent("trace", span), ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("title", span), ast.NewStringType(span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("description", span), ast.NewStringType(span)), + }), + span, + ast.NewNullType(span), + span, + ), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("debug", span), ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("title", span), ast.NewStringType(span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("description", span), ast.NewStringType(span)), + }), + span, + ast.NewNullType(span), + span, + ), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("info", span), ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("title", span), ast.NewStringType(span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("description", span), ast.NewStringType(span)), + }), + span, + ast.NewNullType(span), + span, + ), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("warn", span), ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("title", span), ast.NewStringType(span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("description", span), ast.NewStringType(span)), + }), + span, + ast.NewNullType(span), + span, + ), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("error", span), ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("title", span), ast.NewStringType(span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("description", span), ast.NewStringType(span)), + }), + span, + ast.NewNullType(span), + span, + ), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("fatal", span), ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("title", span), ast.NewStringType(span)), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("description", span), ast.NewStringType(span)), + }), + span, + ast.NewNullType(span), + span, + ), span), + }, span), true, true + default: + return nil, true, false + } + } + return nil, false, false +} + +func (self analyzerHost) ResolveCodeModule(moduleName string) (code string, moduleFound bool, err error) { + log.Trace(fmt.Sprintf("Resolving module `%s` by user `%s`", moduleName, self.username)) + script, found, err := database.GetUserHomescriptById(moduleName, self.username) + if err != nil || !found { + return "", found, err + } + return script.Data.Code, true, nil +} + +// TODO: fill this +func analyzerScopeAdditions() map[string]analyzer.Variable { + return map[string]analyzer.Variable{ + "exit": analyzer.NewBuiltinVar( + ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("code", errors.Span{}), ast.NewIntType(errors.Span{})), + }), + errors.Span{}, + ast.NewNeverType(), + errors.Span{}, + ), + ), + "fmt": analyzer.NewBuiltinVar( + ast.NewFunctionType( + ast.NewVarArgsFunctionTypeParamKind([]ast.Type{ast.NewStringType(errors.Span{})}, ast.NewUnknownType()), + errors.Span{}, + ast.NewStringType(errors.Span{}), + errors.Span{}, + ), + ), + "println": analyzer.NewBuiltinVar( + ast.NewFunctionType( + ast.NewVarArgsFunctionTypeParamKind([]ast.Type{}, ast.NewUnknownType()), + errors.Span{}, + ast.NewNullType(errors.Span{}), + errors.Span{}, + ), + ), + "time": analyzer.NewBuiltinVar(ast.NewObjectType( + []ast.ObjectTypeField{ + ast.NewObjectTypeField( + pAst.NewSpannedIdent("sleep", errors.Span{}), + ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("seconds", errors.Span{}), ast.NewFloatType(errors.Span{})), + }), + errors.Span{}, + ast.NewNullType(errors.Span{}), + errors.Span{}, + ), + errors.Span{}, + ), + ast.NewObjectTypeField( + pAst.NewSpannedIdent("since", errors.Span{}), + ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("when", errors.Span{}), + timeObjType(errors.Span{}), + )}), + errors.Span{}, + durationObjType(errors.Span{}), + errors.Span{}, + ), + errors.Span{}, + ), + ast.NewObjectTypeField( + pAst.NewSpannedIdent("now", errors.Span{}), + ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind(make([]ast.FunctionTypeParam, 0)), + errors.Span{}, + timeObjType(errors.Span{}), + errors.Span{}, + ), + errors.Span{}, + ), + ast.NewObjectTypeField( + pAst.NewSpannedIdent("add_days", errors.Span{}), + ast.NewFunctionType( + ast.NewNormalFunctionTypeParamKind([]ast.FunctionTypeParam{ + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("time", errors.Span{}), timeObjType(errors.Span{})), + ast.NewFunctionTypeParam(pAst.NewSpannedIdent("days", errors.Span{}), ast.NewIntType(errors.Span{})), + }), + errors.Span{}, + timeObjType(errors.Span{}), + errors.Span{}, + ), + errors.Span{}, + ), + }, + errors.Span{}, + )), + } +} + +func durationObjType(span errors.Span) ast.Type { + return ast.NewObjectType([]ast.ObjectTypeField{ + ast.NewObjectTypeField(pAst.NewSpannedIdent("hours", span), ast.NewFloatType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("minutes", span), ast.NewFloatType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("seconds", span), ast.NewFloatType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("millis", span), ast.NewIntType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("display", span), ast.NewFunctionType( + ast.NormalFunctionTypeParamKindIdentifier{}, span, ast.NewStringType(span), span, + ), span), + }, span) +} + +func timeObjType(span errors.Span) ast.Type { + return ast.NewObjectType( + []ast.ObjectTypeField{ + ast.NewObjectTypeField(pAst.NewSpannedIdent("year", span), ast.NewIntType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("month", span), ast.NewIntType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("year_day", span), ast.NewIntType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("hour", span), ast.NewIntType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("minute", span), ast.NewIntType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("second", span), ast.NewIntType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("month_day", span), ast.NewIntType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("week_day", span), ast.NewIntType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("week_day_text", span), ast.NewStringType(span), span), + ast.NewObjectTypeField(pAst.NewSpannedIdent("unix_milli", span), ast.NewIntType(span), span), + }, + span, + ) +} diff --git a/core/homescript/analyzerExecutor.go b/core/homescript/analyzerExecutor.go deleted file mode 100644 index fbed3314..00000000 --- a/core/homescript/analyzerExecutor.go +++ /dev/null @@ -1,265 +0,0 @@ -package homescript - -import ( - "errors" - "fmt" - "net/http" - "net/url" - "time" - "unicode/utf8" - - "github.com/go-ping/ping" - "github.com/smarthome-go/homescript/v2/homescript" - "github.com/smarthome-go/smarthome/core/database" -) - -type AnalyzerExecutor struct { - Username string -} - -func (self AnalyzerExecutor) IsAnalyzer() bool { return true } - -// Resolves a Homescript module -func (self *AnalyzerExecutor) ResolveModule(id string) (string, string, bool, bool, map[string]homescript.Value, error) { - moduleScopeAdditions, exists := builtinModules[id] - if exists { - return "", id, true, true, moduleScopeAdditions, nil - } - - script, found, err := database.GetUserHomescriptById(id, self.Username) - if !found || err != nil { - return "", "", found, true, nil, err - } - return script.Data.Code, id, true, true, make(map[string]homescript.Value), nil -} - -// Resolves a Homescript module -func (self *AnalyzerExecutor) ReadFile(path string) (string, error) { - script, found, err := database.GetUserHomescriptById(path, self.Username) - if err != nil { - return "", err - } - if !found { - return "", fmt.Errorf("Script `%s` was not found but required", path) - } - return script.Data.Code, nil -} - -func (self *AnalyzerExecutor) Sleep(seconds float64) { -} - -func (self *AnalyzerExecutor) Print(args ...string) error { - return nil -} - -func (self *AnalyzerExecutor) Println(args ...string) error { - return nil -} - -func (self *AnalyzerExecutor) GetSwitch(switchId string) (homescript.SwitchResponse, error) { - switchData, found, err := database.GetSwitchById(switchId) - if !found { - return homescript.SwitchResponse{}, fmt.Errorf("switch '%s' was not found", switchId) - } - if err != nil { - return homescript.SwitchResponse{}, err - } - return homescript.SwitchResponse{ - Name: switchData.Name, - Power: switchData.PowerOn, - Watts: uint(switchData.Watts), - }, nil -} - -func (self *AnalyzerExecutor) Switch(switchId string, powerOn bool) error { - _, switchExists, err := database.GetSwitchById(switchId) - if err != nil { - return err - } - if !switchExists { - return fmt.Errorf("Failed to set power: switch '%s' does not exist", switchId) - } - userHasPowerPermission, err := database.UserHasPermission(self.Username, database.PermissionPower) - if err != nil { - return fmt.Errorf("Failed to set power: could not check if user is allowed to interact with switches: %s", err.Error()) - } - if !userHasPowerPermission { - return errors.New("Failed to set power: user is not allowed to interact with switches") - } - userHasSwitchPermission, err := database.UserHasSwitchPermission(self.Username, switchId) - if err != nil { - return fmt.Errorf("Failed to set power: could not check if user is allowed to interact with this switch: %s", err.Error()) - } - if !userHasSwitchPermission { - return fmt.Errorf("Failed to set power: user is not allowed to interact with switch '%s'", switchId) - } - return nil -} - -func (self *AnalyzerExecutor) Ping(ip string, timeoutSecs float64) (bool, error) { - _, err := ping.NewPinger(ip) - if err != nil { - return false, err - } - return false, nil -} - -func (self *AnalyzerExecutor) Get(requestUrl string) (homescript.HttpResponse, error) { - // The permissions can be validated beforehand - hasPermission, err := database.UserHasPermission(self.Username, database.PermissionHomescriptNetwork) - if err != nil { - return homescript.HttpResponse{}, fmt.Errorf("could not send GET request: failed to validate your permissions: %s", err.Error()) - } - if !hasPermission { - return homescript.HttpResponse{}, fmt.Errorf("will not send GET request: you lack permission to access the network via homescript. If this is unintentional, contact your administrator") - } - // Check if the URL is already cached - cached, err := database.IsHomescriptUrlCached(requestUrl) - if err != nil { - return homescript.HttpResponse{}, fmt.Errorf("internal error: Could not check URL cache: %s", err.Error()) - } - if cached { - log.Trace(fmt.Sprintf("Homescript URL `%s` is cached, omitting checks...", requestUrl)) - return homescript.HttpResponse{}, nil - } - log.Trace(fmt.Sprintf("Homescript URL `%s` is not cached, running checks...", requestUrl)) - url, err := url.ParseRequestURI(requestUrl) - if err != nil { - return homescript.HttpResponse{}, fmt.Errorf("invalid URL provided: could not parse URL: %s", err.Error()) - } - if url.Scheme != "http" && url.Scheme != "https" { - return homescript.HttpResponse{}, fmt.Errorf("invalid URL provided: Invalid scheme: `%s`.\n=> Valid schemes are `http` and `https`", url.Scheme) - } - if url.Scheme != "http" && url.Scheme != "https" { - return homescript.HttpResponse{}, fmt.Errorf("invalid URL provided: Invalid scheme: `%s`.\n=> Valid schemes are `http` and `https`", url.Scheme) - } - _, err = http.Head(requestUrl) - if err != nil { - return homescript.HttpResponse{}, err - } - // If all checks were successful, insert the URL into the URL cache - if err := insertCacheEntry(*url); err != nil { - return homescript.HttpResponse{}, fmt.Errorf("internal error: Could not update URL cache entry: %s", err.Error()) - } - log.Trace(fmt.Sprintf("Updated URL cache to include `%s`", requestUrl)) - return homescript.HttpResponse{}, nil -} - -func (self *AnalyzerExecutor) Http( - requestUrl string, - method string, - body string, - headers map[string]string, - cookies map[string]string, -) (homescript.HttpResponse, error) { - // Check permissions and request building beforehand - hasPermission, err := database.UserHasPermission(self.Username, database.PermissionHomescriptNetwork) - if err != nil { - return homescript.HttpResponse{}, fmt.Errorf("could not perform %s request: failed to validate your permissions: %s", method, err.Error()) - } - if !hasPermission { - return homescript.HttpResponse{}, fmt.Errorf("will not perform %s request: you lack permission to access the network via Homescript. If this is unintentional, contact your administrator", method) - } - // Check if the URL is already cached - cached, err := database.IsHomescriptUrlCached(requestUrl) - if err != nil { - return homescript.HttpResponse{}, fmt.Errorf("Internal error: Could not check URL cache: %s", err.Error()) - } - if cached { - log.Trace(fmt.Sprintf("Homescript URL `%s` is cached, omitting checks...", requestUrl)) - return homescript.HttpResponse{}, nil - } - log.Trace(fmt.Sprintf("Homescript URL `%s` is not cached, running checks...", requestUrl)) - - // URL-specific checks - url, err := url.ParseRequestURI(requestUrl) - if err != nil { - return homescript.HttpResponse{}, fmt.Errorf("invalid URL provided: could not parse URL: %s", err.Error()) - } - if url.Scheme != "http" && url.Scheme != "https" { - return homescript.HttpResponse{}, fmt.Errorf("invalid URL provided: Invalid scheme: `%s`.\n=> Valid schemes are `http` and `https`", url.Scheme) - } - if url.Scheme != "http" && url.Scheme != "https" { - return homescript.HttpResponse{}, fmt.Errorf("invalid URL provided: Invalid scheme: `%s`.\n=> Valid schemes are `http` and `https`", url.Scheme) - } - _, err = http.Head(requestUrl) - if err != nil { - return homescript.HttpResponse{}, err - } - // If all checks were successful, insert the URL into the URL cache - if err := insertCacheEntry(*url); err != nil { - return homescript.HttpResponse{}, fmt.Errorf("internal error: Could not update URL cache entry: %s", err.Error()) - } - log.Trace(fmt.Sprintf("updated URL cache to include `%s`", requestUrl)) - return homescript.HttpResponse{}, nil -} - -func (self *AnalyzerExecutor) Notify( - title string, - description string, - level homescript.NotificationLevel, -) error { - return nil -} - -func (self *AnalyzerExecutor) Remind( - title string, - description string, - urgency homescript.ReminderUrgency, - dueDate time.Time, -) (uint, error) { - return 0, nil -} - -func (self *AnalyzerExecutor) Log( - title string, - description string, - level homescript.LogLevel, -) error { - hasPermission, err := database.UserHasPermission(self.Username, database.PermissionLogging) - if err != nil { - return err - } - if !hasPermission { - return fmt.Errorf("failed to add log event: you lack permission to add records to the internal logging system.") - } - if level > 5 { - return fmt.Errorf("failed to add log event: invalid logging level <%d>: valid logging levels are 1, 2, 3, 4, or 5", level) - } - return nil -} - -// Executes another Homescript based on its Id -func (self AnalyzerExecutor) Exec(homescriptId string, args map[string]string) (homescript.ExecResponse, error) { - _, found, err := database.GetUserHomescriptById(homescriptId, self.Username) - if err != nil { - return homescript.ExecResponse{}, err - } - if !found { - return homescript.ExecResponse{}, fmt.Errorf("invalid script: homescript '%s' was not found", homescriptId) - } - return homescript.ExecResponse{ReturnValue: homescript.ValueNull{}}, nil -} - -// Returns the name of the user who is currently running the script -func (self *AnalyzerExecutor) GetUser() string { - return self.Username -} - -func (self *AnalyzerExecutor) GetWeather() (homescript.Weather, error) { - return homescript.Weather{}, nil -} - -func (self *AnalyzerExecutor) GetStorage(key string) (*string, error) { - if utf8.RuneCountInString(key) > 50 { - return nil, errors.New("key is larger than 50 characters") - } - return database.GetHmsStorageEntry(self.Username, key) -} - -func (self *AnalyzerExecutor) SetStorage(key string, value string) error { - if utf8.RuneCountInString(key) > 50 { - return errors.New("key is larger than 50 characters") - } - return nil -} diff --git a/core/homescript/automationRunner.go b/core/homescript/automationRunner.go index 1d9730e6..8cf20e43 100644 --- a/core/homescript/automationRunner.go +++ b/core/homescript/automationRunner.go @@ -2,13 +2,12 @@ package homescript import ( "bytes" + "context" "fmt" "os" "strings" "time" - "github.com/smarthome-go/homescript/v2/homescript" - hmsErrors "github.com/smarthome-go/homescript/v2/homescript/errors" "github.com/smarthome-go/smarthome/core/database" "github.com/smarthome-go/smarthome/core/event" ) @@ -28,7 +27,7 @@ type NotificationContext struct { // Is called when the scheduler executes the given automation // The AutomationRunnerFunc automatically tries to fetch the required configuration from the provided id // Error handling is accomplished by logging to the internal event system and notifying the user about their automations failure -func AutomationRunnerFunc(id uint, context AutomationContext) { +func AutomationRunnerFunc(id uint, automationCtx AutomationContext) { job, jobFound, err := database.GetAutomationById(id) if err != nil { log.Error(fmt.Sprintf("Automation with id: '%d' could not be executed: database failure: %s", id, err.Error())) @@ -197,48 +196,53 @@ func AutomationRunnerFunc(id uint, context AutomationContext) { initiator = InitiatorAutomation } - idChan := make(chan uint64) - if context.MaximumHMSRuntime != nil { - // Kill this automation in the event that it takes too long to execute - go func() { - id := <-idChan - time.Sleep(*context.MaximumHMSRuntime) - if !HmsManager.Kill(id, HmsSigtermRuntimeExceeded) { - log.Fatal(fmt.Sprintf("Could not kill HMS boot job with id %d", id)) - } - }() - } else { - go func() { - <-idChan - }() - } + // idChan := make(chan uint64) + // if automationCtx.MaximumHMSRuntime != nil { + // // Kill this automation in the event that it takes too long to execute + // go func() { + // id := <-idChan + // time.Sleep(*automationCtx.MaximumHMSRuntime) + // if !HmsManager.Kill(id, fmt.Errorf("Maximum automation HMS runtime of %v exceeded", *context.MaximumHMSRuntime)) { + // log.Fatal(fmt.Sprintf("Could not kill HMS boot job with id %d", id)) + // } + // }() + // } else { + // go func() { + // <-idChan + // }() + // } - // Use context information for scope injections - scopeInjections := make(map[string]homescript.Value) + ctx, cancel := context.WithCancel(context.Background()) - if context.NotificationContext != nil { - scopeInjections["context"] = homescript.ValueBuiltinVariable{ - Callback: func(executor homescript.Executor, span hmsErrors.Span) (homescript.Value, *hmsErrors.Error) { - return homescript.ValueObject{IsDynamic: true, IsProtected: true, ObjFields: map[string]*homescript.Value{ - "id": valPtr(homescript.ValueNumber{Value: float64(context.NotificationContext.Id)}), - "title": valPtr(homescript.ValueString{Value: context.NotificationContext.Title}), - "description": valPtr(homescript.ValueString{Value: context.NotificationContext.Description}), - "level": valPtr(homescript.ValueNumber{Value: float64(context.NotificationContext.Level)}), - }}, nil - }, - } + if automationCtx.MaximumHMSRuntime != nil { + ctx, cancel = context.WithTimeout(context.Background(), *automationCtx.MaximumHMSRuntime) } + // Use context information for scope injections // TODO: do this properly, not like this! + // scopeInjections := make(map[string]homescript.Value) + // + // if context.NotificationContext != nil { + // scopeInjections["context"] = homescript.ValueBuiltinVariable{ + // Callback: func(executor homescript.Executor, span hmsErrors.Span) (homescript.Value, *hmsErrors.Error) { + // return homescript.ValueObject{IsDynamic: true, IsProtected: true, ObjFields: map[string]*homescript.Value{ + // "id": valPtr(homescript.ValueNumber{Value: float64(context.NotificationContext.Id)}), + // "title": valPtr(homescript.ValueString{Value: context.NotificationContext.Title}), + // "description": valPtr(homescript.ValueString{Value: context.NotificationContext.Description}), + // "level": valPtr(homescript.ValueNumber{Value: float64(context.NotificationContext.Level)}), + // }}, nil + // }, + // } + // } + res, err := HmsManager.RunById( job.Data.HomescriptId, job.Owner, - make([]string, 0), - make(map[string]string, 0), initiator, - make(chan int), + ctx, + cancel, + nil, + nil, &bytes.Buffer{}, - &idChan, - scopeInjections, ) if err != nil { @@ -259,17 +263,17 @@ func AutomationRunnerFunc(id uint, context AutomationContext) { } return } - if len(res.Errors) > 0 { + if !res.Success { log.Warn(fmt.Sprintf("Automation '%s' failed during the execution of Homescript: '%s', which terminated abnormally", job.Data.Name, job.Data.HomescriptId)) event.Error( "Automation Failed", - fmt.Sprintf("Automation '%s' failed during execution of Homescript '%s'. Error: %s", job.Data.Name, job.Data.HomescriptId, res.Errors[0].Message), + fmt.Sprintf("Automation '%s' failed during execution of Homescript '%s'. Error: %s", job.Data.Name, job.Data.HomescriptId, res.Errors[0]), ) if _, err := Notify( job.Owner, "Automation Failed", - fmt.Sprintf("Automation '**%s**' failed during execution of Homescript '**%s**'.\n```\n%s\n```", job.Data.Name, job.Data.HomescriptId, strings.ReplaceAll(res.Errors[0].Message, "`", "\\`")), + fmt.Sprintf("Automation '**%s**' failed during execution of Homescript '**%s**'.\n```\n%s\n```", job.Data.Name, job.Data.HomescriptId, strings.ReplaceAll(res.Errors[0].String(), "`", "\\`")), NotificationLevelError, false, ); err != nil { @@ -280,6 +284,6 @@ func AutomationRunnerFunc(id uint, context AutomationContext) { } event.Debug( "Automation Executed Successfully", - fmt.Sprintf("Automation `%s` (%d) of user '%s' has executed successfully. HMS-Exit code: %d", job.Data.Name, id, job.Owner, res.ExitCode), + fmt.Sprintf("Automation `%s` (%d) of user '%s' has executed successfully.", job.Data.Name, id, job.Owner), ) } diff --git a/core/homescript/builtin.go b/core/homescript/builtin.go deleted file mode 100644 index 316051e2..00000000 --- a/core/homescript/builtin.go +++ /dev/null @@ -1,669 +0,0 @@ -package homescript - -import ( - "fmt" - "strings" - - "github.com/smarthome-go/homescript/v2/homescript" - "github.com/smarthome-go/homescript/v2/homescript/errors" - hmsErrors "github.com/smarthome-go/homescript/v2/homescript/errors" - "github.com/smarthome-go/smarthome/core/database" - "github.com/smarthome-go/smarthome/core/homescript/automation" -) - -func valPtr(input homescript.Value) *homescript.Value { - return &input -} - -var builtinModules = map[string]map[string]homescript.Value{ - "sys": { - "on_click_hms": homescript.ValueBuiltinFunction{ - Callback: func(executor homescript.Executor, span hmsErrors.Span, args ...homescript.Value) (homescript.Value, *int, *hmsErrors.Error) { - if err := checkArgs("on_click_hms", span, args, homescript.TypeString, homescript.TypeString); err != nil { - return nil, nil, err - } - - targetCode := strings.ReplaceAll(args[0].(homescript.ValueString).Value, "'", "\\'") - targetCode = strings.ReplaceAll(targetCode, "\"", "\\\"") - inner := args[1].(homescript.ValueString).Value - - callBackCode := fmt.Sprintf("fetch('/api/homescript/run/live', {method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: `%s`, args: [] }) })", targetCode) - - wrapper := fmt.Sprintf("%s", callBackCode, inner) - - return homescript.ValueString{Value: wrapper, Range: span}, nil, nil - }, - }, - "on_click_js": homescript.ValueBuiltinFunction{ - Callback: func(executor homescript.Executor, span hmsErrors.Span, args ...homescript.Value) (homescript.Value, *int, *hmsErrors.Error) { - if err := checkArgs("on_click_js", span, args, homescript.TypeString, homescript.TypeString); err != nil { - return nil, nil, err - } - - targetCode := strings.ReplaceAll(args[0].(homescript.ValueString).Value, "\"", "\\\"") - inner := args[1].(homescript.ValueString).Value - - wrapper := fmt.Sprintf("%s", targetCode, inner) - - return homescript.ValueString{Value: wrapper, Range: span}, nil, nil - }, - }, - "system": homescript.ValueBuiltinVariable{ - Callback: func(executor homescript.Executor, span hmsErrors.Span) (homescript.Value, *hmsErrors.Error) { - return homescript.ValueObject{ - IsProtected: true, - DataType: "system", - ObjFields: map[string]*homescript.Value{ - "scheduler_enabled": valPtr(homescript.ValueBuiltinVariable{Callback: func(executor homescript.Executor, span hmsErrors.Span) (homescript.Value, *hmsErrors.Error) { - serverConfig, found, err := database.GetServerConfiguration() - if err != nil || !found { - return nil, errors.NewError(span, "Could not retrieve system configuration", errors.RuntimeError) - } - return homescript.ValueBool{Value: serverConfig.AutomationEnabled}, nil - }}), - "lockdown_mode_enabled": valPtr(homescript.ValueBuiltinVariable{Callback: func(executor homescript.Executor, span hmsErrors.Span) (homescript.Value, *hmsErrors.Error) { - serverConfig, found, err := database.GetServerConfiguration() - if err != nil || !found { - return nil, errors.NewError(span, "Could not retrieve system configuration", errors.RuntimeError) - } - return homescript.ValueBool{Value: serverConfig.LockDownMode}, nil - }}), - "location": valPtr(homescript.ValueBuiltinVariable{Callback: func(executor homescript.Executor, span hmsErrors.Span) (homescript.Value, *hmsErrors.Error) { - serverConfig, found, err := database.GetServerConfiguration() - if err != nil || !found { - return nil, errors.NewError(span, "Could not retrieve system configuration", errors.RuntimeError) - } - return homescript.ValueObject{DataType: "location", ObjFields: map[string]*homescript.Value{ - "lat": valPtr(homescript.ValueNumber{Value: float64(serverConfig.Latitude)}), - "lon": valPtr(homescript.ValueNumber{Value: float64(serverConfig.Longitude)}), - }}, nil - }}), - "sun_times": valPtr(homescript.ValueBuiltinVariable{Callback: func(executor homescript.Executor, span hmsErrors.Span) (homescript.Value, *hmsErrors.Error) { - serverConfig, found, err := database.GetServerConfiguration() - if err != nil || !found { - return nil, errors.NewError(span, "Could not retrieve system configuration", errors.RuntimeError) - } - - rise, set := automation.CalculateSunRiseSet(serverConfig.Latitude, serverConfig.Longitude) - - return homescript.ValueObject{DataType: "sun_times", ObjFields: map[string]*homescript.Value{ - "sunrise": valPtr(homescript.ValueObject{DataType: "time_simple", ObjFields: map[string]*homescript.Value{ - "hour": valPtr(homescript.ValueNumber{Value: float64(rise.Hour)}), - "minute": valPtr(homescript.ValueNumber{Value: float64(rise.Minute)}), - }}), - "sunset": valPtr(homescript.ValueObject{DataType: "time_simple", ObjFields: map[string]*homescript.Value{ - "hour": valPtr(homescript.ValueNumber{Value: float64(set.Hour)}), - "minute": valPtr(homescript.ValueNumber{Value: float64(set.Minute)}), - }}), - }}, nil - }}), - "hardware": valPtr(homescript.ValueBuiltinVariable{Callback: func(executor homescript.Executor, span hmsErrors.Span) (homescript.Value, *hmsErrors.Error) { - hasPermission, err := database.UserHasPermission(executor.GetUser(), database.PermissionSystemConfig) - if err != nil { - return nil, errors.NewError(span, err.Error(), errors.RuntimeError) - } - - if !hasPermission { - return nil, errors.NewError(span, fmt.Sprintf("Permission denied: you lack the permission `%s`", database.PermissionSystemConfig), errors.RuntimeError) - } - - outList := make([]*homescript.Value, 0) - typeObj := homescript.TypeObject - - if executor.IsAnalyzer() { - return homescript.ValueList{ValueType: &typeObj, Values: &outList}, nil - } - - nodes, err := database.GetHardwareNodes() - if err != nil { - return nil, errors.NewError(span, err.Error(), errors.RuntimeError) - } - - for _, node := range nodes { - nameCopy := node.Name - urlCopy := node.Url - tokenCopy := node.Token - onlineCopy := node.Online - enabledCopy := node.Enabled - - currObj := homescript.ValueObject{ - DataType: "hw_node", - IsDynamic: false, - ObjFields: map[string]*homescript.Value{ - "name": valPtr(homescript.ValueString{Value: nameCopy}), - "online": valPtr(homescript.ValueBool{Value: onlineCopy}), - "enabled": valPtr(homescript.ValueBool{Value: enabledCopy}), - "url": valPtr(homescript.ValueString{Value: urlCopy}), - "token": valPtr(homescript.ValueString{Value: tokenCopy}), - "set_enabled": valPtr(homescript.ValueBuiltinFunction{Callback: func(executor homescript.Executor, span hmsErrors.Span, args ...homescript.Value) (homescript.Value, *int, *hmsErrors.Error) { - if err := checkArgs("set_enabled", span, args, homescript.TypeBoolean); err != nil { - return nil, nil, err - } - - shouldEnable := args[0].(homescript.ValueBool).Value - - fmt.Printf("setting %s to %t\n", urlCopy, shouldEnable) - - if err := database.ModifyHardwareNode(urlCopy, shouldEnable, nameCopy, tokenCopy); err != nil { - return nil, nil, errors.NewError(span, err.Error(), errors.RuntimeError) - } - - return homescript.ValueNull{}, nil, nil - }}), - }, - - Range: span, - IsProtected: true, - } - - outList = append(outList, valPtr(currObj)) - } - - return homescript.ValueList{ - Values: &outList, - ValueType: &typeObj, - Range: span, - IsProtected: true, - }, nil - }}), - }, - }, nil - }, - }, - "automation": homescript.ValueBuiltinVariable{ - Callback: func(executor homescript.Executor, span hmsErrors.Span) (homescript.Value, *hmsErrors.Error) { - return homescript.ValueObject{ - DataType: "automation", - ObjFields: map[string]*homescript.Value{ - "new": valPtr(homescript.ValueBuiltinFunction{ - Callback: func(executor homescript.Executor, span hmsErrors.Span, args ...homescript.Value) (homescript.Value, *int, *hmsErrors.Error) { - if err := checkArgs("new", span, args, homescript.TypeObject); err != nil { - return nil, nil, err - } - - obj := args[0].(homescript.ValueObject) - valErr, stop := checkObj(span, obj, map[string]homescript.ValueType{ - "name": homescript.TypeString, - "description": homescript.TypeString, - "hour": homescript.TypeNumber, - "minute": homescript.TypeNumber, - "hms_id": homescript.TypeString, - "days": homescript.TypeList, - }, executor) - if valErr != nil { - return nil, nil, valErr - } - if stop { - return homescript.ValueNumber{Value: 0.0}, nil, nil - } - - fields, fieldErr := obj.Fields(executor, span) - if fieldErr != nil { - return nil, nil, fieldErr - } - - name := (*fields["name"]).(homescript.ValueString) - description := (*fields["description"]).(homescript.ValueString) - hour := (*fields["hour"]).(homescript.ValueNumber) - minute := (*fields["minute"]).(homescript.ValueNumber) - hmsId := (*fields["hms_id"]).(homescript.ValueString) - days := (*fields["days"]).(homescript.ValueList) - - if err := checkInt(span, hour, "Field `hour`"); err != nil { - return nil, nil, err - } - if err := checkInt(span, minute, "Field `minute`"); err != nil { - return nil, nil, err - } - - if hour.Value < 0.0 || hour.Value > 24.0 { - return nil, nil, errors.NewError(span, "Hour must be => 0 and <= 24", errors.ValueError) - } - - if minute.Value < 0.0 || minute.Value > 60.0 { - return nil, nil, errors.NewError(span, "Minute must be => 0 and <= 60", errors.ValueError) - } - - data, exists, err := database.GetUserHomescriptById(hmsId.Value, executor.GetUser()) - if err != nil { - return nil, nil, errors.NewError(span, err.Error(), errors.RuntimeError) - } - - if !exists { - return nil, nil, errors.NewError(span, fmt.Sprintf("Homescript with ID `%s` does not exist", hmsId.Value), errors.ValueError) - } - - if data.Data.SchedulerEnabled { - return nil, nil, errors.NewError(span, fmt.Sprintf("Homescript with ID `%s` cannot be used as an automation / scheduler target", hmsId.Value), errors.ValueError) - } - - if len(*days.Values) == 0 || len(*days.Values) > 7 { - return nil, nil, errors.NewError(span, fmt.Sprintf("Invalid `days` list: expected >= 0 and <=7, got `%d`", len(*days.Values)), errors.ValueError) - } - - // Check for duplicates and if each provided day is valid - containsDays := make([]uint8, 0) // Contains the days, is used to check if there are duplicates in the days - for idx, day := range *days.Values { - if err := checkInt(span, (*day).(homescript.ValueNumber), fmt.Sprintf("Day at index `%d` invalid: ", idx)); err != nil { - return nil, nil, err - } - - dayInt := int((*day).(homescript.ValueNumber).Value) - if dayInt > 6 { - return nil, nil, errors.NewError(span, fmt.Sprintf("invalid day in `days`: day must be >= 0 and <= 6, found `%d`", day), errors.ValueError) - } - dayIsAlreadyPresend := false - for _, dayTemp := range containsDays { - if dayTemp == uint8(dayInt) { - dayIsAlreadyPresend = true - } - } - if dayIsAlreadyPresend { - return nil, nil, errors.NewError(span, fmt.Sprintf("Duplicate entry in `days` list: `%d`", dayInt), errors.ValueError) - } - containsDays = append(containsDays, uint8(dayInt)) // If the day is not already present, add it - } - - if executor.IsAnalyzer() { - return homescript.ValueNumber{Value: 0.0}, nil, nil - } - h := uint(hour.Value) - m := uint(minute.Value) - - id, err := CreateNewAutomation(name.Value, description.Value, hmsId.Value, executor.GetUser(), true, &h, &m, &containsDays, database.TriggerCron, nil) - if err != nil { - return nil, nil, hmsErrors.NewError(span, err.Error(), errors.RuntimeError) - } - return homescript.ValueNumber{Value: float64(id)}, nil, nil - }, - }), - "list": valPtr(homescript.ValueBuiltinFunction{ - Callback: func(executor homescript.Executor, span hmsErrors.Span, args ...homescript.Value) (homescript.Value, *int, *hmsErrors.Error) { - if err := checkArgs("list", span, args); err != nil { - return nil, nil, err - } - - output := make([]*homescript.Value, 0) - - automations, err := database.GetUserAutomations(executor.GetUser()) - if err != nil { - return nil, nil, hmsErrors.NewError(span, err.Error(), errors.RuntimeError) - } - - for _, automationItem := range automations { - triggerInterval := valPtr(homescript.ValueNull{}) - if automationItem.Data.TriggerIntervalSeconds != nil { - triggerInterval = valPtr(homescript.ValueNumber{Value: float64(*automationItem.Data.TriggerIntervalSeconds)}) - } - - triggerCronExpression := valPtr(homescript.ValueNull{}) - if automationItem.Data.TriggerCronExpression != nil { - triggerCronExpression = valPtr(homescript.ValueString{Value: *automationItem.Data.TriggerCronExpression}) - } - - lastRun := valPtr(homescript.ValueNull{}) - if automationItem.Data.LastRun != nil { - lastRun = valPtr(homescript.ValueNumber{Value: float64(automationItem.Data.LastRun.UnixMilli())}) - } - - output = append(output, valPtr(homescript.ValueObject{ - DataType: "automation", - ObjFields: map[string]*homescript.Value{ - "id": valPtr(homescript.ValueNumber{Value: float64(automationItem.Id)}), - "name": valPtr(homescript.ValueString{ - Value: automationItem.Data.Name, - }), - "description": valPtr(homescript.ValueString{ - Value: automationItem.Data.Description, - }), - "homescript_id": valPtr(homescript.ValueString{Value: automationItem.Data.HomescriptId}), - "enabled": valPtr(homescript.ValueBool{Value: automationItem.Data.Enabled}), - "disable_once": valPtr(homescript.ValueBool{Value: automationItem.Data.DisableOnce}), - "last_run": lastRun, - "trigger": valPtr(homescript.ValueString{ - Value: string(automationItem.Data.Trigger), - }), - "trigger_cron_expression": triggerCronExpression, - "trigger_interval": triggerInterval, - }, - })) - } - - type_ := homescript.TypeObject - return homescript.ValueList{Values: &output, ValueType: &type_}, nil, nil - }, - }), - "delete": valPtr(homescript.ValueBuiltinFunction{ - Callback: func(executor homescript.Executor, span hmsErrors.Span, args ...homescript.Value) (homescript.Value, *int, *hmsErrors.Error) { - if err := checkArgs("delete", span, args, homescript.TypeNumber); err != nil { - return nil, nil, err - } - - id := args[0].(homescript.ValueNumber).Value - - if float64(int(id)) != id || id < 0.0 { - return nil, nil, errors.NewError(span, fmt.Sprintf("Illegal value: ID needs to be a positive integer, got `%f`", id), errors.ValueError) - } - - automationData, found, err := database.GetAutomationById(uint(id)) - if err != nil { - return nil, nil, errors.NewError(span, err.Error(), errors.RuntimeError) - } - - if !found || automationData.Owner != executor.GetUser() { - return nil, nil, errors.NewError(span, fmt.Sprintf("Automation with ID `%d` does not exist", int(id)), errors.ValueError) - } - - if executor.IsAnalyzer() { - return homescript.ValueNull{}, nil, nil - } - - if err := RemoveAutomation(uint(id)); err != nil { - return nil, nil, errors.NewError(span, err.Error(), errors.RuntimeError) - } - - return homescript.ValueNull{}, nil, nil - }, - }), - }, - }, nil - }, - }, - "scheduler": homescript.ValueBuiltinVariable{ - Callback: func(executor homescript.Executor, span hmsErrors.Span) (homescript.Value, *hmsErrors.Error) { - return homescript.ValueObject{ - - DataType: "scheduler", - ObjFields: map[string]*homescript.Value{ - "new": valPtr(homescript.ValueBuiltinFunction{ - Callback: func(executor homescript.Executor, span hmsErrors.Span, args ...homescript.Value) (homescript.Value, *int, *hmsErrors.Error) { - if err := checkArgs("new", span, args, homescript.TypeObject); err != nil { - return nil, nil, err - } - - obj := args[0].(homescript.ValueObject) - valErr, stop := checkObj(span, obj, map[string]homescript.ValueType{ - "name": homescript.TypeString, - "hour": homescript.TypeNumber, - "minute": homescript.TypeNumber, - "code": homescript.TypeString, - }, executor) - if valErr != nil { - return nil, nil, valErr - } - if stop { - return homescript.ValueNumber{Value: 0.0}, nil, nil - } - - fields, fieldErr := obj.Fields(executor, span) - if fieldErr != nil { - return nil, nil, fieldErr - } - - name := (*fields["name"]).(homescript.ValueString) - hour := (*fields["hour"]).(homescript.ValueNumber) - minute := (*fields["minute"]).(homescript.ValueNumber) - code := (*fields["code"]).(homescript.ValueString) - - if err := checkInt(span, hour, "Field `hour`"); err != nil { - return nil, nil, err - } - if err := checkInt(span, minute, "Field `minute`"); err != nil { - return nil, nil, err - } - - if hour.Value < 0.0 || hour.Value > 24.0 { - return nil, nil, errors.NewError(span, "Hour must be => 0 and <= 24", errors.ValueError) - } - - if minute.Value < 0.0 || minute.Value > 60.0 { - return nil, nil, errors.NewError(span, "Minute must be => 0 and <= 60", errors.ValueError) - } - - if executor.IsAnalyzer() { - return homescript.ValueNumber{Value: 0.0}, nil, nil - } - - id, err := CreateNewSchedule(database.ScheduleData{ - Name: name.Value, - Hour: uint(hour.Value), - Minute: uint(minute.Value), - TargetMode: database.ScheduleTargetModeCode, - HomescriptCode: code.Value, - }, executor.GetUser()) - if err != nil { - return nil, nil, hmsErrors.NewError(span, err.Error(), errors.RuntimeError) - } - return homescript.ValueNumber{Value: float64(id)}, nil, nil - }, - }), - "modify": valPtr(homescript.ValueBuiltinFunction{ - Callback: func(executor homescript.Executor, span hmsErrors.Span, args ...homescript.Value) (homescript.Value, *int, *hmsErrors.Error) { - if err := checkArgs("modify", span, args, homescript.TypeNumber, homescript.TypeObject); err != nil { - return nil, nil, err - } - - obj := args[1].(homescript.ValueObject) - valErr, stop := checkObj(span, obj, map[string]homescript.ValueType{ - "name": homescript.TypeString, - "hour": homescript.TypeNumber, - "minute": homescript.TypeNumber, - "code": homescript.TypeString, - }, executor) - if valErr != nil { - return nil, nil, valErr - } - if stop { - return homescript.ValueNull{}, nil, nil - } - - fields, fieldErr := obj.Fields(executor, span) - if fieldErr != nil { - return nil, nil, fieldErr - } - - name := (*fields["name"]).(homescript.ValueString) - hour := (*fields["hour"]).(homescript.ValueNumber) - minute := (*fields["minute"]).(homescript.ValueNumber) - code := (*fields["code"]).(homescript.ValueString) - - if err := checkInt(span, hour, "Field `hour`"); err != nil { - return nil, nil, err - } - if err := checkInt(span, minute, "Field `minute`"); err != nil { - return nil, nil, err - } - - if hour.Value < 0.0 || hour.Value > 24.0 { - return nil, nil, errors.NewError(span, "Hour must be => 0 and <= 24", errors.ValueError) - } - if minute.Value < 0.0 || minute.Value > 60.0 { - return nil, nil, errors.NewError(span, "Minute must be => 0 and <= 60", errors.ValueError) - } - - id := args[0].(homescript.ValueNumber) - if err := checkInt(span, id, "argument `id` is not an integer"); err != nil { - return nil, nil, err - } - - if executor.IsAnalyzer() { - return homescript.ValueNull{}, nil, nil - } - - schedulerData, found, err := database.GetScheduleById(uint(id.Value)) - if err != nil { - return nil, nil, errors.NewError(span, err.Error(), errors.RuntimeError) - } - - if !found || schedulerData.Owner != executor.GetUser() { - return nil, nil, errors.NewError(span, fmt.Sprintf("Schedule with ID `%d` does not exist", int(id.Value)), errors.ValueError) - } - - if err := ModifyScheduleById(uint(id.Value), database.ScheduleData{ - Name: name.Value, - Hour: uint(hour.Value), - Minute: uint(minute.Value), - TargetMode: database.ScheduleTargetModeCode, - HomescriptCode: code.Value, - }); err != nil { - return nil, nil, hmsErrors.NewError(span, err.Error(), errors.RuntimeError) - } - return homescript.ValueNull{}, nil, nil - }, - }), - "delete": valPtr(homescript.ValueBuiltinFunction{ - Callback: func(executor homescript.Executor, span hmsErrors.Span, args ...homescript.Value) (homescript.Value, *int, *hmsErrors.Error) { - if err := checkArgs("delete", span, args, homescript.TypeNumber); err != nil { - return nil, nil, err - } - - id := args[0].(homescript.ValueNumber).Value - - if float64(int(id)) != id || id < 0.0 { - return nil, nil, errors.NewError(span, fmt.Sprintf("Illegal value: ID needs to be a positive integer, got `%f`", id), errors.ValueError) - } - - schedulerData, found, err := database.GetScheduleById(uint(id)) - if err != nil { - return nil, nil, errors.NewError(span, err.Error(), errors.RuntimeError) - } - - if !found || schedulerData.Owner != executor.GetUser() { - return nil, nil, errors.NewError(span, fmt.Sprintf("Schedule with ID `%d` does not exist", int(id)), errors.ValueError) - } - - if executor.IsAnalyzer() { - return homescript.ValueNull{}, nil, nil - } - - if err := RemoveScheduleById(uint(id)); err != nil { - return nil, nil, errors.NewError(span, err.Error(), errors.RuntimeError) - } - - return homescript.ValueNull{}, nil, nil - }, - }), - "list": valPtr(homescript.ValueBuiltinFunction{ - Callback: func(executor homescript.Executor, span hmsErrors.Span, args ...homescript.Value) (homescript.Value, *int, *hmsErrors.Error) { - if err := checkArgs("list", span, args); err != nil { - return nil, nil, err - } - - output := make([]*homescript.Value, 0) - - schedules, err := database.GetUserSchedules(executor.GetUser()) - if err != nil { - return nil, nil, hmsErrors.NewError(span, err.Error(), errors.RuntimeError) - } - - for _, schedule := range schedules { - var hmsCode homescript.Value = homescript.ValueNull{} - var hmsId homescript.Value = homescript.ValueNull{} - var switchJobs homescript.Value = homescript.ValueNull{} - - switch schedule.Data.TargetMode { - case database.ScheduleTargetModeCode: - hmsCode = homescript.ValueString{Value: schedule.Data.HomescriptCode} - case database.ScheduleTargetModeHMS: - hmsId = homescript.ValueString{Value: schedule.Data.HomescriptTargetId} - case database.ScheduleTargetModeSwitches: - objType := homescript.TypeObject - - switches := make([]*homescript.Value, 0) - for _, switchJob := range schedule.Data.SwitchJobs { - switches = append(switches, valPtr(homescript.ValueObject{ - ObjFields: map[string]*homescript.Value{ - "id": valPtr(homescript.ValueString{Value: switchJob.SwitchId}), - "power": valPtr(homescript.ValueBool{Value: switchJob.PowerOn}), - }, - })) - } - - switchJobs = homescript.ValueList{Values: &switches, ValueType: &objType} - } - output = append(output, valPtr(homescript.ValueObject{ - DataType: "schedule", - ObjFields: map[string]*homescript.Value{ - "id": valPtr(homescript.ValueNumber{Value: float64(schedule.Id)}), - "name": valPtr(homescript.ValueString{ - Value: schedule.Data.Name, - }), - "target_mode": valPtr(homescript.ValueString{ - Value: string(schedule.Data.TargetMode), - }), - "hms_code": valPtr(hmsCode), - "hms_id": valPtr(hmsId), - "switches": valPtr(switchJobs), - "hour": valPtr(homescript.ValueNumber{Value: float64(schedule.Data.Hour)}), - "minute": valPtr(homescript.ValueNumber{Value: float64(schedule.Data.Minute)}), - }, - })) - } - - type_ := homescript.TypeObject - return homescript.ValueList{Values: &output, ValueType: &type_}, nil, nil - }, - }), - }, - }, nil - }, - }, - }, -} - -// Helper function which checks the validity of args provided to builtin functions -func checkArgs(name string, span errors.Span, args []homescript.Value, types ...homescript.ValueType) *errors.Error { - if len(args) != len(types) { - s := "" - if len(types) != 1 { - s = "s" - } - return errors.NewError( - span, - fmt.Sprintf("function '%s' takes %d argument%s but %d were given", name, len(types), s, len(args)), - errors.TypeError, - ) - } - for i, typ := range types { - if args[i].Type() != typ { - return errors.NewError( - span, - fmt.Sprintf("Argument %d of function '%s' has to be of type %v", i+1, name, typ), - errors.TypeError, - ) - } - } - return nil -} - -// Helper function which checks that the passed object contains the correct keys -func checkObj(span errors.Span, obj homescript.ValueObject, check map[string]homescript.ValueType, executor homescript.Executor) (*errors.Error, bool) { - fields, err := obj.Fields(executor, span) - if err != nil { - return err, false - } - - for key, type_ := range check { - - if fields[key] == nil { - return errors.NewError(span, fmt.Sprintf("Key `%s` of type `%v` not found in object", key, type_.String()), errors.TypeError), false - } - - if (*fields[key]) == nil { - return nil, true - } - - if (*fields[key]).Type() != type_ { - return errors.NewError(span, fmt.Sprintf("Key `%s` has type `%v`, however `%v` was expected", key, (*fields[key]).Type(), type_.String()), errors.TypeError), false - } - } - - return nil, false -} - -func checkInt(span errors.Span, num homescript.ValueNumber, errPrefix string) *errors.Error { - if float64(int(num.Value)) != num.Value { - return errors.NewError(span, fmt.Sprintf("%s: expected integer, found `%f`", errPrefix, num.Value), errors.ValueError) - } - return nil -} diff --git a/core/homescript/executor.go b/core/homescript/executor.go index a963f291..e21c988e 100644 --- a/core/homescript/executor.go +++ b/core/homescript/executor.go @@ -1,748 +1,18 @@ package homescript -import ( - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "sync" - "time" - "unicode/utf8" - - "golang.org/x/net/context" - - "github.com/go-ping/ping" - "github.com/smarthome-go/homescript/v2/homescript" - "github.com/smarthome-go/smarthome/core/database" - "github.com/smarthome-go/smarthome/core/event" - "github.com/smarthome-go/smarthome/core/hardware" - "github.com/smarthome-go/smarthome/services/weather" -) - type Executor struct { - // Is required for error handling and recursion prevention - // Allows pretty-print for potential errors - ScriptName string - - // Specifies the time a script was started - // Can be used to keep track of the script's runtime - StartTime time.Time - - // The Username is required for functions which rely on permissions-check - // or need to access the username for other reasons, e.g. `notify` - Username string - - // Output writer for asynchronous Homescript output (for example via the Web-UI) - OutputWriter io.Writer - - // How many bytes were already written to the io writer - BytesWritten uint - - // If set to true, a script will only check its correctness - // Does not actually modify or wait for any data - DryRun bool - - // Holds the script's arguments as a map - // Is filled by a helper function like `Run` - // Is used by the `CheckArg` and `GetArg` methods for providing args - Args map[string]string - - // The CallStack saves the history of which script called another - // Additionally, it specifies which Homescripts have to be excluded from the `exec` function - // Is required in order to prevent recursion until the system's database runs out of resources - // Acts like a blacklist which holds the blacklisted Homescript ids - // The last item in the CallStack is the script which was called the most recently (from script exit) - CallStack []string - - // Pointer to the interpreter's sigTerm channel - // Is used here in order to allow the abortion during expensive operations, e.g. the sleep function - sigTermInternalPtr *chan int - - // Sigterm receiver which is visible to the outside - // Any signal will be forwarded to the internal `sigTermPtr` - SigTerm chan int - - // Is set to true as soon as a sigTerm is received - // Is required and read by the manager's run functions and by HMS `exec` calls - // Used in order to determine whether a script has been terminated using a sigTerm or if it exited conventionally - WasTerminated bool - - // Specifies if the executor is currently inside a builtin function - // Is required in the manager to dispatch the sigTerm to the correct channel - InExpensiveBuiltin struct { - Mutex sync.Mutex - Value bool - } - - // Used for certain internal things, such as preventing recursive notification hooks - Initiator HomescriptInitiator -} - -// Is used to allow the abortion of a running script at any point in time -// => Checks if a sigTerm has been received -// Is used to break out of expensive operations, for example sleep calls -// Only a bool static that a code has been received is returned -// => The real sigTerm handling is done in the AST execution of the interpreter -func (self *Executor) checkSigTerm() bool { - select { - case code := <-self.SigTerm: - // Forwards the signal to the interpreter - go func() { - // This goroutine is required because otherwise, - // the sending of the signal would block forever - // This is due to the interpreter only handling sigTerms on every AST-node - // However, the interpreter will only handle the next node if this function's caller quits - // Because of this, not using a goroutine would invoke a deadlock - *self.sigTermInternalPtr <- code - }() - - // Set the `WasTerminated` boolean to true - // Is required so that the manager's run functions are informed about this script's death cause - self.WasTerminated = true - - return true - default: - return false - } -} - -func (self *Executor) IsAnalyzer() bool { - return false -} - -// Resolves a Homescript module -func (self *Executor) ResolveModule(id string) (string, string, bool, bool, map[string]homescript.Value, error) { - moduleScopeAdditions, exists := builtinModules[id] - if exists { - return "", id, true, true, moduleScopeAdditions, nil - } - - script, found, err := database.GetUserHomescriptById(id, self.Username) - if !found || err != nil { - return "", "", found, true, nil, err - } - return script.Data.Code, id, true, true, make(map[string]homescript.Value), nil } // Resolves a Homescript module -func (self *Executor) ReadFile(path string) (string, error) { - script, found, err := database.GetUserHomescriptById(path, self.Username) - if err != nil { - return "", err - } - if !found { - return "", fmt.Errorf("Script `%s` was not found but required", path) - } - return script.Data.Code, nil -} - -// Pauses the execution of the current script for the amount of the specified seconds -// Implements special checks to cancel the sleep function during its execution -func (self *Executor) Sleep(seconds float64) { - if self.DryRun { - return - } - self.InExpensiveBuiltin.Mutex.Lock() - self.InExpensiveBuiltin.Value = true - self.InExpensiveBuiltin.Mutex.Unlock() - - defer func() { - self.InExpensiveBuiltin.Mutex.Lock() - self.InExpensiveBuiltin.Value = false - self.InExpensiveBuiltin.Mutex.Unlock() - }() - for i := 0; i < int(seconds*1000); i += 10 { - if self.checkSigTerm() { - // Sleep function is terminated - // Additional wait time is used to dispatch the signal to the interpreter - //time.Sleep(time.Millisecond * 10) - break - } - time.Sleep(time.Millisecond * 10) - } -} - -const IO_WRITER_BYTES_CAP = 5 * 1_000_000 // X * 1 Megabyte - -func (self *Executor) writeIO(data []byte) error { - bytes, err := self.OutputWriter.Write(data) - if err != nil { - return err - } - - self.BytesWritten += uint(bytes) - - if self.BytesWritten > IO_WRITER_BYTES_CAP { - return fmt.Errorf("IO writer capacity of %d bytes exceeded", IO_WRITER_BYTES_CAP) - } - - return nil -} - -// Emulates printing to the console -// Instead, appends the provided message to the output of the executor -// Exists in order to return the script's output to the user -func (self *Executor) Print(args ...string) error { - if self.DryRun { - return nil - } - - if err := self.writeIO([]byte(strings.Join(args, " "))); err != nil { - return err - } - - return nil -} - -// Emulates printing to the console -// Instead, appends the provided message to the output of the executor -// Exists in order to return the script's output to the user -// Just like `Print` but appends a newline to the end -func (self *Executor) Println(args ...string) error { - if self.DryRun { - return nil - } - - if err := self.writeIO([]byte(strings.Join(args, " ") + "\n")); err != nil { - return err - } - - return nil -} - -// Returns an object with contains data about the requested switch -// Returns an error if the provided switch does not exist -func (self *Executor) GetSwitch(switchId string) (homescript.SwitchResponse, error) { - switchData, found, err := database.GetSwitchById(switchId) - if !found { - return homescript.SwitchResponse{}, fmt.Errorf("switch '%s' was not found", switchId) - } - if err != nil { - log.Debug(fmt.Sprintf("[Homescript] ERROR: script: '%s' user: '%s': failed to read power state: %s", self.ScriptName, self.Username, err.Error())) - return homescript.SwitchResponse{}, err - } - return homescript.SwitchResponse{ - Name: switchData.Name, - Power: switchData.PowerOn, - Watts: uint(switchData.Watts), - }, nil -} - -// Changes the power state of an arbitrary switch -// Checks if the switch exists, if the user is allowed to interact with switches and if the user has the matching switch-permission -// If a check fails, an error is returned -func (self *Executor) Switch(switchId string, powerOn bool) error { - // If running in DryRun, only check the values - if self.DryRun { - return self.testSwitch(switchId, powerOn) - } - // Actual function implementation - err := hardware.SetSwitchPowerAll(switchId, powerOn, self.Username) - if err != nil { - log.Debug(fmt.Sprintf("[Homescript] ERROR: script: '%s' user: '%s': failed to set power: %s", self.ScriptName, self.Username, err.Error())) - return err - } - onOffText := "on" - if !powerOn { - onOffText = "off" - } - log.Debug(fmt.Sprintf("[Homescript] script: '%s' user: '%s': turning switch %s %s", self.ScriptName, self.Username, switchId, onOffText)) - return nil -} - -// Used for DryRun, only checks the existence of the specified switch and the user's permissions -func (self *Executor) testSwitch(switchId string, powerOn bool) error { - _, switchExists, err := database.GetSwitchById(switchId) - if err != nil { - return err - } - if !switchExists { - return fmt.Errorf("Failed to set power: switch '%s' does not exist", switchId) - } - userHasPowerPermission, err := database.UserHasPermission(self.Username, database.PermissionPower) - if err != nil { - return fmt.Errorf("Failed to set power: could not check if user is allowed to interact with switches: %s", err.Error()) - } - if !userHasPowerPermission { - return errors.New("Failed to set power: user is not allowed to interact with switches") - } - userHasSwitchPermission, err := database.UserHasSwitchPermission(self.Username, switchId) - if err != nil { - return fmt.Errorf("Failed to set power: could not check if user is allowed to interact with this switch: %s", err.Error()) - } - if !userHasSwitchPermission { - return fmt.Errorf("Failed to set power: user is not allowed to interact with switch '%s'", switchId) - } - return nil -} - -// Performs a ICMP ping and returns a boolean which states whether the target host is online or offline -func (self *Executor) Ping(ip string, timeoutSecs float64) (bool, error) { - // Is still executed because the function tries to resolve the specified IP (allows some checking) - pinger, err := ping.NewPinger(ip) - if err != nil { - return false, err - } - // If DryRun is being used, stop here - if self.DryRun { - return false, nil - } - // Perform the ping - pinger.Count = 1 - pinger.Timeout = time.Millisecond * time.Duration(timeoutSecs*1000) - err = pinger.Run() // Blocks until the ping is finished or timed-out - if err != nil { - return false, err - } - stats := pinger.Statistics() - return stats.PacketsRecv > 0, nil // If at least 1 packet was received back, the host is considered online -} - -// Makes a GET request to an arbitrary URL and returns the result -func (self *Executor) Get(requestUrl string) (homescript.HttpResponse, error) { - // The permissions can be validated beforehand - hasPermission, err := database.UserHasPermission(self.Username, database.PermissionHomescriptNetwork) - if err != nil { - return homescript.HttpResponse{}, fmt.Errorf("could not send GET request: failed to validate your permissions: %s", err.Error()) - } - if !hasPermission { - return homescript.HttpResponse{}, fmt.Errorf("will not send GET request: you lack permission to access the network via homescript. If this is unintentional, contact your administrator") - } - - // DryRun only checks the URL's validity - if self.DryRun { - // Check if the URL is already cached - cached, err := database.IsHomescriptUrlCached(requestUrl) - if err != nil { - return homescript.HttpResponse{}, fmt.Errorf("internal error: Could not check URL cache: %s", err.Error()) - } - if cached { - log.Trace(fmt.Sprintf("Homescript URL `%s` is cached, omitting checks...", requestUrl)) - return homescript.HttpResponse{}, nil - } - log.Trace(fmt.Sprintf("Homescript URL `%s` is not cached, running checks...", requestUrl)) - url, err := url.ParseRequestURI(requestUrl) - if err != nil { - return homescript.HttpResponse{}, fmt.Errorf("invalid URL provided: could not parse URL: %s", err.Error()) - } - if url.Scheme != "http" && url.Scheme != "https" { - return homescript.HttpResponse{}, fmt.Errorf("invalid URL provided: Invalid scheme: `%s`.\n=> Valid schemes are `http` and `https`", url.Scheme) - } - if url.Scheme != "http" && url.Scheme != "https" { - return homescript.HttpResponse{}, fmt.Errorf("invalid URL provided: Invalid scheme: `%s`.\n=> Valid schemes are `http` and `https`", url.Scheme) - } - _, err = http.Head(requestUrl) - if err != nil { - return homescript.HttpResponse{}, err - } - // If all checks were successful, insert the URL into the URL cache - if err := insertCacheEntry(*url); err != nil { - return homescript.HttpResponse{}, fmt.Errorf("internal error: Could not update URL cache entry: %s", err.Error()) - } - log.Trace(fmt.Sprintf("Updated URL cache to include `%s`", requestUrl)) - return homescript.HttpResponse{}, nil - } - - // Create a new request - req, err := http.NewRequest( - http.MethodGet, - requestUrl, - nil, - ) - if err != nil { - return homescript.HttpResponse{}, err - } - // Set the user agent to the Smarthome HMS client - req.Header.Set("User-Agent", "Smarthome-homescript") - - // Create a new context for cancellatioon - ctx := context.Background() - ctx, cancel := context.WithCancel(ctx) - req = req.WithContext(ctx) - - // Start the http request monitor Go routine - requestHasFinished := make(chan struct{}) - go self.httpCancelationMonitor( - ctx, - cancel, - requestHasFinished, - ) - - // Perform the request - // Create a client for the request - client := http.Client{ - // Set the client's timeout to 60 seconds - Timeout: 60 * time.Second, - } - - res, err := client.Do(req) - - // Evaluate the request's outcome - if err != nil { - return homescript.HttpResponse{}, err - } - - // Stop the request monitor Go routine - requestHasFinished <- struct{}{} - - // Read request response body - defer res.Body.Close() - resBody, err := io.ReadAll(res.Body) - if err != nil { - return homescript.HttpResponse{}, err - } - - cookies := make([]http.Cookie, 0) - for _, cookie := range res.Cookies() { - cookies = append(cookies, *cookie) - } - - return homescript.HttpResponse{ - Status: res.Status, - StatusCode: uint16(res.StatusCode), - Body: string(resBody), - Cookies: cookies, - }, nil -} - -// Makes a request to an arbitrary URL using a custom method and body in order to return the result -func (self *Executor) Http(requestUrl string, method string, body string, headers map[string]string, cookies map[string]string) (homescript.HttpResponse, error) { - // Check permissions and request building beforehand - hasPermission, err := database.UserHasPermission(self.Username, database.PermissionHomescriptNetwork) - if err != nil { - return homescript.HttpResponse{}, fmt.Errorf("could not perform %s request: failed to validate your permissions: %s", method, err.Error()) - } - if !hasPermission { - return homescript.HttpResponse{}, fmt.Errorf("will not perform %s request: you lack permission to access the network via Homescript. If this is unintentional, contact your administrator", method) - } - - // If using DryRun, stop here and just validate the request URL - if self.DryRun { - // Check if the URL is already cached - cached, err := database.IsHomescriptUrlCached(requestUrl) - if err != nil { - return homescript.HttpResponse{}, fmt.Errorf("Internal error: Could not check URL cache: %s", err.Error()) - } - if cached { - log.Trace(fmt.Sprintf("Homescript URL `%s` is cached, omitting checks...", requestUrl)) - return homescript.HttpResponse{}, nil - } - log.Trace(fmt.Sprintf("Homescript URL `%s` is not cached, running checks...", requestUrl)) - - // URL-specific checks - url, err := url.ParseRequestURI(requestUrl) - if err != nil { - return homescript.HttpResponse{}, fmt.Errorf("invalid URL provided: could not parse URL: %s", err.Error()) - } - if url.Scheme != "http" && url.Scheme != "https" { - return homescript.HttpResponse{}, fmt.Errorf("invalid URL provided: Invalid scheme: `%s`.\n=> Valid schemes are `http` and `https`", url.Scheme) - } - if url.Scheme != "http" && url.Scheme != "https" { - return homescript.HttpResponse{}, fmt.Errorf("invalid URL provided: Invalid scheme: `%s`.\n=> Valid schemes are `http` and `https`", url.Scheme) - } - _, err = http.Head(requestUrl) - if err != nil { - return homescript.HttpResponse{}, err - } - // If all checks were successful, insert the URL into the URL cache - if err := insertCacheEntry(*url); err != nil { - return homescript.HttpResponse{}, fmt.Errorf("internal error: Could not update URL cache entry: %s", err.Error()) - } - log.Trace(fmt.Sprintf("updated URL cache to include `%s`", requestUrl)) - return homescript.HttpResponse{}, nil - } - - // Create a new request - req, err := http.NewRequest( - method, - requestUrl, - strings.NewReader(body), - ) - if err != nil { - return homescript.HttpResponse{}, err - } - // Set the user agent to the Smarthome HMS client - req.Header.Set("User-Agent", "Smarthome-homescript") - - // Set the headers included via the function call - for headerKey, headerValue := range headers { - req.Header.Set(headerKey, headerValue) - } - - // Set the cookies - for key, value := range cookies { - c := http.Cookie{ - Name: key, - Value: value, - } - req.AddCookie(&c) - } - - // Create a new context - ctx := context.Background() - ctx, cancel := context.WithCancel(ctx) - req = req.WithContext(ctx) - - // Start the http request monitor Go routine - requestHasFinished := make(chan struct{}) - go self.httpCancelationMonitor( - ctx, - cancel, - requestHasFinished, - ) - - // Perform the request - // Create a client for the request - client := http.Client{ - // Set the client's timeout to 60 seconds - Timeout: 60 * time.Second, - } - res, err := client.Do(req) - // Evaluate the request's outcome - if err != nil { - return homescript.HttpResponse{}, err - } - - // Stop the request monitor Go routine - requestHasFinished <- struct{}{} - - // Read request response body - defer res.Body.Close() - resBody, err := io.ReadAll(res.Body) - if err != nil { - return homescript.HttpResponse{}, err - } - - outCookies := make([]http.Cookie, 0) - for _, cookie := range res.Cookies() { - outCookies = append(outCookies, *cookie) - } - - return homescript.HttpResponse{ - Status: res.Status, - StatusCode: uint16(res.StatusCode), - Body: string(resBody), - Cookies: outCookies, - }, nil -} - -func (self *Executor) httpCancelationMonitor( - cnt context.Context, - cancelRequest context.CancelFunc, - requestHasFinished chan struct{}, -) { - self.InExpensiveBuiltin.Mutex.Lock() - self.InExpensiveBuiltin.Value = true - self.InExpensiveBuiltin.Mutex.Unlock() - - defer func() { - self.InExpensiveBuiltin.Mutex.Lock() - self.InExpensiveBuiltin.Value = false - self.InExpensiveBuiltin.Mutex.Unlock() - log.Trace("Finished Homescript http request monitoring") - }() - - log.Trace("Started Homescript http request monitoring") - for { - select { - // If the request has finished regularely, stop the monitor and to nothing - case <-requestHasFinished: - log.Trace("Homescript http request finished regularely") - return - // If the request has not finished, run the check below - default: - // If a sigTerm is received whilst waiting for the request to be completed, cancel the request and stop the monitor - if self.checkSigTerm() { - log.Debug("Detected sigTerm while waiting for Homescript http request to be completed: canceling request...") - cancelRequest() - return - } - time.Sleep(10 * time.Millisecond) - } - } -} - -// Sends a notification to the user who issues this command -func (self *Executor) Notify( - title string, - description string, - level homescript.NotificationLevel, -) error { - // If using DryRun, stop here - if self.DryRun { - return nil - } - _, err := Notify( - self.Username, - title, - description, - NotificationLevel(level+1), - self.Initiator != InitiatorAutomationOnNotify, // Do not trigger other hooks if this Homescript is itself a hook - ) - if err != nil { - log.Error(fmt.Sprintf("[Homescript] ERROR: script: '%s' user: '%s': failed to notify user: %s", self.ScriptName, self.Username, err.Error())) - } - return nil -} - -// Add a reminder to the user's reminders. -func (self *Executor) Remind( - title string, - description string, - urgency homescript.ReminderUrgency, - dueDate time.Time, -) (uint, error) { - // If using DryRun, stop here - if self.DryRun { - return 0, nil - } - id, err := database.CreateNewReminder( - title, - description, - dueDate, - self.Username, - database.ReminderPriority(urgency), - ) - if err != nil { - log.Error(fmt.Sprintf("[Homescript] ERROR: script: '%s' user: '%s': failed to add reminder to user: %s", self.ScriptName, self.Username, err.Error())) - } - return id, err -} - -// Adds a log entry to the internal logging system -func (self *Executor) Log( - title string, - description string, - level homescript.LogLevel, -) error { - hasPermission, err := database.UserHasPermission(self.Username, database.PermissionLogging) - if err != nil { - return err - } - if !hasPermission { - return fmt.Errorf("Failed to add log event: you lack permission to add records to the internal logging system.") - } - // If using DryRun, stop here - if self.DryRun { - if level > 5 { - return fmt.Errorf("Failed to add log event: invalid logging level <%d>: valid logging levels are 1, 2, 3, 4, or 5", level) - } - return nil - } - switch level { - case 0: - event.Trace(title, description) - case 1: - event.Debug(title, description) - case 2: - event.Info(title, description) - case 3: - event.Warn(title, description) - case 4: - event.Error(title, description) - case 5: - event.Fatal(title, description) - default: - return fmt.Errorf("Failed to add log event: invalid logging level <%d>: valid logging levels are 1, 2, 3, 4, or 5", level) - } - return nil -} - -// Executes another Homescript based on its Id -func (self *Executor) Exec(homescriptId string, args map[string]string) (homescript.ExecResponse, error) { - // The dryRun value is passed to the executed script - // Before the target script can begin execution, the call stack is analyzed in order to prevent recursion - - // If the CallStack is empty, the script was initially called by string - // In this case, the own id must be appended to the CallStack fist - if len(self.CallStack) == 0 { - self.CallStack = append(self.CallStack, self.ScriptName) - } - - // Analyze call stack - for _, call := range self.CallStack { - if homescriptId == call { - // Would call a script which is already located in the CallStack (upstream) - // In order to show the problem to the user, the stack is unwinded and transformed into a pretty display - callStackVisual := "=== Call Stack ===\n" - for callIndex, callVis := range self.CallStack { - if callIndex == 0 { - callStackVisual += fmt.Sprintf(" %2d: %-10s (INITIAL)\n", 0, self.CallStack[0]) - } else { - callStackVisual += fmt.Sprintf(" %2d: %-10s\n", callIndex, callVis) - } - } - callStackVisual += fmt.Sprintf(" %2d: %-10s (PREVENTED)\n", len(self.CallStack), homescriptId) - return homescript.ExecResponse{}, fmt.Errorf("Exec violation: executing '%s' could cause infinite recursion.\n%s", homescriptId, callStackVisual) - } - } - // Execute the target script after the checks - start := time.Now() - res, err := HmsManager.RunById( - homescriptId, - self.Username, - // The previous CallStack is passed in order to preserve the history - // Because the RunById function implicitly appends its target id the provided call stack, it doesn't need to be added here - self.CallStack, - args, - InitiatorExec, - self.SigTerm, - self.OutputWriter, - nil, - make(map[string]homescript.Value), - ) - // Check if the script was killed using a sigTerm - if res.WasTerminated { - self.WasTerminated = true - return homescript.ExecResponse{}, fmt.Errorf("Exec received sigTerm whilst processing Homescript `%s`", homescriptId) - } - if err != nil { - return homescript.ExecResponse{}, err - } - if len(res.Errors) > 0 { - return homescript.ExecResponse{}, fmt.Errorf("%s: %s (%d:%d)", res.Errors[0].Kind, res.Errors[0].Message, res.Errors[0].Span.Start.Line, res.Errors[0].Span.Start.Column) - } - return homescript.ExecResponse{ - RuntimeSecs: float64(time.Since(start).Seconds()), - ReturnValue: res.ReturnValue, - RootScope: res.RootScope, - }, nil -} - -// Returns the name of the user who is currently running the script -func (self *Executor) GetUser() string { - return self.Username -} - -func (self *Executor) GetWeather() (homescript.Weather, error) { - if self.DryRun { - return homescript.Weather{}, nil - } - wthr, err := weather.GetCurrentWeather() - if err != nil { - return homescript.Weather{}, fmt.Errorf("could not fetch weather: %s", err.Error()) - } - return homescript.Weather{ - WeatherTitle: wthr.WeatherTitle, - WeatherDescription: wthr.WeatherDescription, - Temperature: float64(wthr.Temperature), - FeelsLike: float64(wthr.FeelsLike), - Humidity: wthr.Humidity, - }, nil -} - -func (self *Executor) GetStorage(key string) (*string, error) { - if utf8.RuneCountInString(key) > 50 { - return nil, errors.New("key is larger than 50 characters") - } - return database.GetHmsStorageEntry(self.Username, key) -} - -func (self *Executor) SetStorage(key string, value string) error { - if utf8.RuneCountInString(key) > 50 { - return errors.New("key is larger than 50 characters") - } - return database.InsertHmsStorageEntry(self.Username, key, value) -} +// func (self *Executor) ResolveModule(id string) (string, string, bool, bool, map[string]homescript.Value, error) { +// moduleScopeAdditions, exists := builtinModules[id] +// if exists { +// return "", id, true, true, moduleScopeAdditions, nil +// } +// +// script, found, err := database.GetUserHomescriptById(id, self.Username) +// if !found || err != nil { +// return "", "", found, true, nil, err +// } +// return script.Data.Code, id, true, true, make(map[string]homescript.Value), nil +// } diff --git a/core/homescript/homescript_test.go b/core/homescript/homescript_test.go index c53ecc19..eb48b9f9 100644 --- a/core/homescript/homescript_test.go +++ b/core/homescript/homescript_test.go @@ -11,7 +11,7 @@ import ( "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" - "github.com/smarthome-go/homescript/v2/homescript" + "github.com/smarthome-go/homescript/v3/homescript" "github.com/smarthome-go/smarthome/core/database" "github.com/smarthome-go/smarthome/core/event" "github.com/smarthome-go/smarthome/core/hardware" diff --git a/core/homescript/interpreterBuiltin.go b/core/homescript/interpreterBuiltin.go new file mode 100644 index 00000000..089a98a2 --- /dev/null +++ b/core/homescript/interpreterBuiltin.go @@ -0,0 +1,642 @@ +package homescript + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/go-ping/ping" + "github.com/smarthome-go/homescript/v3/homescript/errors" + "github.com/smarthome-go/homescript/v3/homescript/interpreter/value" + "github.com/smarthome-go/smarthome/core/database" + "github.com/smarthome-go/smarthome/core/event" + "github.com/smarthome-go/smarthome/core/hardware" +) + +type interpreterExecutor struct { + username string + ioWriter io.Writer +} + +func (self interpreterExecutor) GetUser() string { + return self.username +} + +func newInterpreterExecutor(username string, writer io.Writer) interpreterExecutor { + return interpreterExecutor{ + username: username, + ioWriter: writer, + } +} + +func parseDate(year, month, day int) (time.Time, bool) { + t := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.Local) + y, m, d := t.Date() + return t, y == year && int(m) == month && d == day +} + +// if it exists, returns a value which is part of the host builtin modules +func (self interpreterExecutor) GetBuiltinImport(moduleName string, toImport string) (val value.Value, found bool) { + switch moduleName { + case "switch": + switch toImport { + case "power": + return *value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + switchId := args[0].(value.ValueString).Inner + powerOn := args[1].(value.ValueBool).Inner + + err := hardware.SetSwitchPowerAll(switchId, powerOn, self.username) + if err != nil { + return nil, value.NewRuntimeErr(err.Error(), value.HostErrorKind, span) + } + + return value.NewValueNull(), nil + }), true + default: + return nil, true + } + case "widget": + switch toImport { + case "on_click_js": + return *value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + targetCode := strings.ReplaceAll(args[0].(value.ValueString).Inner, "\"", "\\\"") + inner := args[1].(value.ValueString).Inner + wrapper := fmt.Sprintf("%s", targetCode, inner) + return value.NewValueString(wrapper), nil + }), true + case "on_click_hms": + return *value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + targetCode := strings.ReplaceAll(args[0].(value.ValueString).Inner, "'", "\\'") + targetCode = strings.ReplaceAll(targetCode, "\"", "\\\"") + inner := args[1].(value.ValueString).Inner + callBackCode := fmt.Sprintf("fetch('/api/homescript/run/live', {method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: `%s`, args: [] }) })", targetCode) + wrapper := fmt.Sprintf("%s", callBackCode, inner) + return value.NewValueString(wrapper), nil + }), true + default: + return nil, false + } + case "testing": + switch toImport { + case "assert_eq": + return *value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + lhsDisp, i := args[0].Display() + if i != nil { + return nil, i + } + rhsDisp, i := args[1].Display() + if i != nil { + return nil, i + } + + if args[0].Kind() != args[1].Kind() { + return nil, value.NewThrowInterrupt(span, fmt.Sprintf("`%s` is not equal to `%s`", lhsDisp, rhsDisp)) + } + + isEqual, i := args[0].IsEqual(args[1]) + if i != nil { + return nil, i + } + + if !isEqual { + return nil, value.NewThrowInterrupt(span, fmt.Sprintf("`%s` is not equal to `%s`", lhsDisp, rhsDisp)) + } + + return value.NewValueNull(), nil + }), true + } + case "storage": + switch toImport { + case "set_storage": + return *value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + key := args[0].(value.ValueString).Inner + disp, i := args[1].Display() + if i != nil { + return nil, i + } + + if err := database.InsertHmsStorageEntry(executor.GetUser(), key, disp); err != nil { + return nil, value.NewRuntimeErr( + fmt.Sprintf("Could not set storage: %s", err.Error()), + value.HostErrorKind, + span, + ) + } + + return value.NewValueNull(), nil + }), true + case "get_storage": + return *value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + key := args[0].(value.ValueString).Inner + + val, err := database.GetHmsStorageEntry(executor.GetUser(), key) + if err != nil { + return nil, value.NewRuntimeErr( + fmt.Sprintf("Could not set storage: %s", err.Error()), + value.HostErrorKind, + span, + ) + } + + valTemp := "" + if val != nil { + valTemp = *val + } + + fields := map[string]*value.Value{ + "value": value.NewValueString(valTemp), + "found": value.NewValueBool(val != nil), + } + + return value.NewValueObject(fields), nil + }), true + } + case "reminder": + switch toImport { + case "remind": + return *value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + fields := args[0].(value.ValueObject).FieldsInternal + + title := (*fields["title"]).(value.ValueString).Inner + description := (*fields["title"]).(value.ValueString).Inner + priority := (*fields["priority"]).(value.ValueInt).Inner + dueDateDay := (*fields["due_date_day"]).(value.ValueInt).Inner + dueDateMonth := (*fields["due_date_month"]).(value.ValueInt).Inner + dueDateYear := (*fields["due_date_year"]).(value.ValueInt).Inner + + if priority < 1.0 || priority > 5.0 { + return nil, value.NewRuntimeErr( + fmt.Sprintf("Reminder urgency has to be 0 < and < 6, got %d", int(priority)), + value.ValueErrorKind, + span, + ) + } + + dueDate, valid := parseDate(int(dueDateYear), int(dueDateMonth), int(dueDateDay)) + if !valid { + return nil, value.NewRuntimeErr( + "Invalid due date provided", + value.ValueErrorKind, + span, + ) + } + + newId, err := database.CreateNewReminder( + title, + description, + dueDate, + executor.GetUser(), + database.ReminderPriority(priority), + ) + if err != nil { + return nil, value.NewRuntimeErr( + fmt.Sprintf("Could not create reminder: %s", err.Error()), + value.HostErrorKind, + span, + ) + } + + return value.NewValueInt(int64(newId)), nil + }), true + } + case "net": + switch toImport { + case "ping": + return *value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + ip := args[0].(value.ValueString).Inner + timeout := args[1].(value.ValueFloat).Inner + + pinger, err := ping.NewPinger(ip) + if err != nil { + return nil, value.NewThrowInterrupt( + span, + err.Error(), + ) + } + + // perform the ping + pinger.Count = 1 + pinger.Timeout = time.Millisecond * time.Duration(timeout*1000) + err = pinger.Run() // blocks until the ping is finished or timed-out + if err != nil { + return nil, value.NewThrowInterrupt( + span, + err.Error(), + ) + } + stats := pinger.Statistics() + return value.NewValueBool(stats.PacketsRecv > 0), nil // If at least 1 packet was received back, the host is considered online + }), true + case "http": + return *value.NewValueObject(map[string]*value.Value{ + "get": value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + hasPermission, err := database.UserHasPermission(self.username, database.PermissionHomescriptNetwork) + if err != nil { + return nil, value.NewRuntimeErr( + fmt.Sprintf("Could not send GET request: failed to validate user's permissions: %s", err.Error()), + value.HostErrorKind, + span, + ) + } + if !hasPermission { + return nil, value.NewRuntimeErr( + fmt.Sprintf("will not send GET request: you lack permission to access the network via homescript. If this is unintentional, contact your administrator"), + value.HostErrorKind, + span, + ) + } + + url := args[0].(value.ValueString).Inner + + // Create a new request + req, err := http.NewRequest( + http.MethodGet, + url, + nil, + ) + if err != nil { + return nil, value.NewRuntimeErr(err.Error(), value.HostErrorKind, span) + } + // Set the user agent to the Smarthome HMS client + req.Header.Set("User-Agent", "Smarthome-Homescript") + + // Create a new context for cancellatioon + req = req.WithContext(*cancelCtx) + + // Perform the request + // Create a client for the request + client := http.Client{ + // Set the client's timeout to 60 seconds + Timeout: 60 * time.Second, + } + + res, err := client.Do(req) + + // Evaluate the request's outcome + if err != nil { + return nil, value.NewRuntimeErr(err.Error(), value.HostErrorKind, span) + } + + // Read request response body + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, value.NewRuntimeErr(err.Error(), value.HostErrorKind, span) + } + + outCookies := make(map[string]*value.Value) + for _, cookie := range res.Cookies() { + outCookies[cookie.Name] = value.NewValueString(cookie.Value) + } + + return value.NewValueObject(map[string]*value.Value{ + "status": value.NewValueString(res.Status), + "status_code": value.NewValueInt(int64(res.StatusCode)), + "body": value.NewValueString(string(resBody)), + "cookies": value.NewValueAnyObject(outCookies), + }), nil + }), + "generic": value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + // Check permissions and request building beforehand + hasPermission, err := database.UserHasPermission(executor.GetUser(), database.PermissionHomescriptNetwork) + if err != nil { + return nil, value.NewRuntimeErr( + fmt.Sprintf("Could not perform request: failed to validate your permissions: %s", err.Error()), + value.HostErrorKind, + span, + ) + } + if !hasPermission { + return nil, value.NewRuntimeErr( + fmt.Sprintf("Will not perform request: lacking permission to access the network via Homescript. If this is unintentional, contact your administrator"), + value.HostErrorKind, + span, + ) + } + + url := args[0].(value.ValueString).Inner + method := args[1].(value.ValueString).Inner + body := args[2].(value.ValueOption) + headers := args[3].(value.ValueAnyObject).FieldsInternal + cookies := args[4].(value.ValueAnyObject).FieldsInternal + + var bodyStr string + if body.IsSome() { + bodyStr = (*body.Inner).(value.ValueString).Inner + } + + // Create a new request + req, err := http.NewRequest( + method, + url, + strings.NewReader(bodyStr), + ) + if err != nil { + return nil, value.NewThrowInterrupt( + span, + err.Error(), + ) + } + // Set the user agent to the Smarthome HMS client + req.Header.Set("User-Agent", "Smarthome-homescript") + + // Set the headers included via the function call + for headerKey, headerValue := range headers { + disp, i := (*headerValue).Display() + if i != nil { + return nil, i + } + + req.Header.Set(headerKey, disp) + } + + // Set the cookies + for cookieKey, cookieValue := range cookies { + disp, i := (*cookieValue).Display() + if i != nil { + return nil, i + } + + c := http.Cookie{ + Name: cookieKey, + Value: disp, + } + req.AddCookie(&c) + } + + req = req.WithContext(*cancelCtx) + + // Perform the request + // Create a client for the request + client := http.Client{ + // Set the client's timeout to 60 seconds + Timeout: 60 * time.Second, + } + res, err := client.Do(req) + // Evaluate the request's outcome + if err != nil { + return nil, value.NewThrowInterrupt( + span, + err.Error(), + ) + } + + // Read request response body + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, value.NewThrowInterrupt( + span, + err.Error(), + ) + } + + outCookies := make(map[string]*value.Value) + for _, cookie := range res.Cookies() { + outCookies[cookie.Name] = value.NewValueString(cookie.Value) + } + + return value.NewValueObject(map[string]*value.Value{ + "status": value.NewValueString(res.Status), + "status_code": value.NewValueInt(int64(res.StatusCode)), + "body": value.NewValueString(string(resBody)), + "cookies": value.NewValueAnyObject(outCookies), + }), nil + }), + }), true + default: + return nil, false + } + case "log": + switch toImport { + case "logger": + testPermissions := func(username string, span errors.Span) *value.Interrupt { + hasPermission, err := database.UserHasPermission(self.GetUser(), database.PermissionLogging) + if err != nil { + return value.NewRuntimeErr(err.Error(), value.HostErrorKind, span) + } + if !hasPermission { + return value.NewRuntimeErr(fmt.Sprintf("Failed to add log event: lacking permission to add records to the internal logging system."), value.HostErrorKind, span) + } + return nil + } + + return *value.NewValueObject(map[string]*value.Value{ + "trace": value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + title := args[0].(value.ValueString).Inner + description := args[1].(value.ValueString).Inner + if i := testPermissions(executor.GetUser(), span); i != nil { + return nil, i + } + event.Trace(title, description) + return value.NewValueNull(), nil + }), + "debug": value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + title := args[0].(value.ValueString).Inner + description := args[1].(value.ValueString).Inner + if i := testPermissions(executor.GetUser(), span); i != nil { + return nil, i + } + event.Debug(title, description) + return value.NewValueNull(), nil + }), + "info": value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + title := args[0].(value.ValueString).Inner + description := args[1].(value.ValueString).Inner + if i := testPermissions(executor.GetUser(), span); i != nil { + return nil, i + } + event.Info(title, description) + return value.NewValueNull(), nil + }), + "warn": value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + title := args[0].(value.ValueString).Inner + description := args[1].(value.ValueString).Inner + if i := testPermissions(executor.GetUser(), span); i != nil { + return nil, i + } + event.Warn(title, description) + return value.NewValueNull(), nil + }), + "error": value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + title := args[0].(value.ValueString).Inner + description := args[1].(value.ValueString).Inner + if i := testPermissions(executor.GetUser(), span); i != nil { + return nil, i + } + event.Error(title, description) + return value.NewValueNull(), nil + }), + "fatal": value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + title := args[0].(value.ValueString).Inner + description := args[1].(value.ValueString).Inner + if i := testPermissions(executor.GetUser(), span); i != nil { + return nil, i + } + event.Fatal(title, description) + return value.NewValueNull(), nil + }), + }), true + } + } + return nil, false +} + +// returns the Homescript code of the requested module +func (self interpreterExecutor) ResolveModuleCode(moduleName string) (code string, found bool, err error) { + return "", false, nil +} + +// Writes the given string (produced by a print function for instance) to any arbitrary source +func (self interpreterExecutor) WriteStringTo(input string) error { + self.ioWriter.Write([]byte(input)) // TODO: does this even work? + return nil +} + +func checkCancelation(ctx *context.Context, span errors.Span) *value.Interrupt { + select { + case <-(*ctx).Done(): + return value.NewTerminationInterrupt((*ctx).Err().Error(), span) + default: + // do nothing, this should not block the entire interpreter + return nil + } +} + +func interpreterScopeAdditions() map[string]value.Value { + // TODO: fill this + return map[string]value.Value{ + "exit": *value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + return nil, value.NewExitInterrupt(args[0].(value.ValueInt).Inner) + }), + "fmt": *value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + displays := make([]any, 0) + + for idx, arg := range args { + if idx == 0 { + continue + } + + var out any + + switch arg.Kind() { + case value.NullValueKind: + out = "null" + case value.IntValueKind: + out = arg.(value.ValueInt).Inner + case value.FloatValueKind: + out = arg.(value.ValueFloat).Inner + case value.BoolValueKind: + out = arg.(value.ValueBool).Inner + case value.StringValueKind: + out = arg.(value.ValueString).Inner + default: + display, i := arg.Display() + if i != nil { + return nil, i + } + out = display + } + + displays = append(displays, out) + } + + out := fmt.Sprintf(args[0].(value.ValueString).Inner, displays...) + + return value.NewValueString(out), nil + }), + "println": *value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + output := make([]string, 0) + for _, arg := range args { + disp, i := arg.Display() + if i != nil { + return nil, i + } + output = append(output, disp) + } + + outStr := strings.Join(output, " ") + "\n" + + if err := executor.WriteStringTo(outStr); err != nil { + return nil, value.NewRuntimeErr( + err.Error(), + value.HostErrorKind, + span, + ) + } + + return value.NewValueNull(), nil + }), + "time": *value.NewValueObject(map[string]*value.Value{ + "sleep": value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + durationSecs := args[0].(value.ValueFloat).Inner + + for i := 0; i < int(durationSecs*1000); i += 10 { + if i := checkCancelation(cancelCtx, span); i != nil { + return nil, i + } + time.Sleep(time.Millisecond * 10) + } + + return nil, nil + }), + "since": value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + milliObj := args[0].(value.ValueObject).FieldsInternal["unix_milli"] + then := time.UnixMilli((*milliObj).(value.ValueInt).Inner) + return createDurationObject(time.Since(then)), nil + }), + "add_days": value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + base := createTimeStructFromObject(args[0]) + days := args[1].(value.ValueInt).Inner + return createTimeObject(base.Add(time.Hour * 24 * time.Duration(days))), nil + }), + "now": value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + now := time.Now() + + return createTimeObject(now), nil + }), + }), + } +} + +func createDurationObject(t time.Duration) *value.Value { + return value.NewValueObject(map[string]*value.Value{ + "hours": value.NewValueFloat(t.Hours()), + "minutes": value.NewValueFloat(t.Minutes()), + "seconds": value.NewValueFloat(t.Seconds()), + "millis": value.NewValueInt(t.Milliseconds()), + "display": value.NewValueBuiltinFunction(func(executor value.Executor, cancelCtx *context.Context, span errors.Span, args ...value.Value) (*value.Value, *value.Interrupt) { + return value.NewValueString(t.String()), nil + }), + }) +} + +func createTimeObject(t time.Time) *value.Value { + return value.NewValueObject( + map[string]*value.Value{ + "year": value.NewValueInt(int64(t.Year())), + "month": value.NewValueInt(int64(t.Month())), + "year_day": value.NewValueInt(int64(t.YearDay())), + "hour": value.NewValueInt(int64(t.Hour())), + "minute": value.NewValueInt(int64(t.Minute())), + "second": value.NewValueInt(int64(t.Second())), + "month_day": value.NewValueInt(int64(t.Day())), + "week_day": value.NewValueInt(int64(t.Weekday())), + "week_day_text": value.NewValueString(t.Weekday().String()), + "unix_milli": value.NewValueInt(t.UnixMilli()), + }, + ) +} + +func createTimeStructFromObject(t value.Value) time.Time { + tObj := t.(value.ValueObject) + fields, i := tObj.Fields() + if i != nil { + panic(i) + } + millis := (*fields["unix_milli"]).(value.ValueInt).Inner + return time.UnixMilli(millis) +} diff --git a/core/homescript/manager.go b/core/homescript/manager.go index 03040587..a3b568f5 100644 --- a/core/homescript/manager.go +++ b/core/homescript/manager.go @@ -1,41 +1,60 @@ package homescript import ( - "errors" + "context" "fmt" "io" "sync" - "time" - "github.com/smarthome-go/homescript/v2/homescript" - hmsErrors "github.com/smarthome-go/homescript/v2/homescript/errors" + "github.com/davecgh/go-spew/spew" + "github.com/smarthome-go/homescript/v3/homescript" + "github.com/smarthome-go/homescript/v3/homescript/analyzer/ast" + "github.com/smarthome-go/homescript/v3/homescript/diagnostic" + "github.com/smarthome-go/homescript/v3/homescript/errors" + "github.com/smarthome-go/homescript/v3/homescript/interpreter/value" "github.com/smarthome-go/smarthome/core/database" ) -type HomescriptInitiator string +// this can be decremented if a script uses too many ressources +const CALL_STACK_LIMIT_SIZE = 2048 + +type HomescriptInitiator uint8 const ( - InitiatorAutomation HomescriptInitiator = "automation" - InitiatorAutomationOnNotify HomescriptInitiator = "automation_on_notify" - InitiatorScheduler HomescriptInitiator = "scheduler" - InitiatorExec HomescriptInitiator = "exec_target" - InitiatorInternal HomescriptInitiator = "internal" - InitiatorAPI HomescriptInitiator = "api" - InitiatorWidget HomescriptInitiator = "widget" + InitiatorAutomation HomescriptInitiator = iota // triggered by a normal automation + InitiatorAutomationOnNotify // triggered by an automation which runs on every notification + InitiatorSchedule // triggered by a schedule + InitiatorExec // triggered by a call to `exec` + InitiatorInternal // triggered internally + InitiatorAPI // triggered through the API + InitiatorWidget // triggered through a widget ) -type HomescriptSigterm int +// +// Homescript manager +// -const ( - HmsSigtermSuccess HomescriptSigterm = 0 - HmsSigtermCanceled HomescriptSigterm = 10 - HmsSigtermRuntimeExceeded HomescriptSigterm = 20 -) +type Manager struct { + Lock sync.RWMutex + Jobs []Job +} + +type Job struct { + Username string + JobId uint64 + HmsId *string + Initiator HomescriptInitiator + CancelCtx context.CancelFunc +} + +// For external usage (can be marshaled) +type ApiJob struct { + Jobid uint64 `json:"jobId"` + HmsId *string `json:"hmsId"` +} -// Global manager var HmsManager Manager -// Initializes the Homescript manager func InitManager() { HmsManager = Manager{ Lock: sync.RWMutex{}, @@ -43,302 +62,288 @@ func InitManager() { } } -type Manager struct { - Lock sync.RWMutex - Jobs []Job +// +// Results and errors +// + +type HmsRes struct { + Success bool + Errors []HmsError + FileContents map[string]string } -type Job struct { - Id uint64 `json:"id"` - Initiator HomescriptInitiator `json:"initiator"` - Executor *Executor `json:"executor"` +type HmsError struct { + SyntaxError *HmsSyntaxError `json:"syntaxError"` + DiagnosticError *HmsDiagnosticError `json:"diagnosticError"` + RuntimeInterrupt *HmsRuntimeInterrupt `json:"runtimeError"` + Span errors.Span `json:"span"` } -type ApiJob struct { - Id uint64 `json:"id"` - Initiator HomescriptInitiator `json:"initiator"` - HomescriptId string `json:"homescriptId"` +func (self HmsError) String() string { + spanDisplay := fmt.Sprintf("%s:%d:%d", self.Span.Filename, self.Span.Start.Line, self.Span.Start.Column) + if self.SyntaxError != nil { + return fmt.Sprintf("Syntax error at %s: %s", spanDisplay, self.SyntaxError.Message) + } else if self.DiagnosticError != nil { + return "Semantic error" + } else if self.RuntimeInterrupt != nil { + return fmt.Sprintf("%s at %s: %s", self.RuntimeInterrupt.Kind, spanDisplay, self.RuntimeInterrupt.Message) + } + panic("Illegal HmsError") } -type HmsExecRes struct { - ReturnValue homescript.Value - RootScope map[string]*homescript.Value - ExitCode int - WasTerminated bool - Errors []HmsError +type HmsSyntaxError struct { + Message string `json:"message"` } -type HmsError struct { - Kind string `json:"kind"` - Message string `json:"message"` - Span hmsErrors.Span `json:"span"` - FileContents string `json:"code"` +type HmsDiagnosticError struct { + Level diagnostic.DiagnosticLevel `json:"kind"` + Message string `json:"message"` + Notes []string `json:"notes"` } -func convertErrors(input []hmsErrors.Error) []HmsError { - output := make([]HmsError, 0) - for _, err := range input { - output = append(output, HmsError{ - Kind: err.Kind.String(), - Message: err.Message, - Span: err.Span, - }) - } - return output +type HmsRuntimeInterrupt struct { + Kind string `json:"kind"` + Message string `json:"message"` } func (m *Manager) PushJob( - executor *Executor, + username string, initiator HomescriptInitiator, - idReceiver chan uint64, + cancelCtxFunc context.CancelFunc, + hmsId *string, ) uint64 { m.Lock.Lock() id := uint64(len(m.Jobs)) m.Jobs = append(m.Jobs, Job{ - Id: id, - Executor: executor, + Username: username, + JobId: id, + HmsId: hmsId, Initiator: initiator, + CancelCtx: cancelCtxFunc, }) m.Lock.Unlock() return id } -func (m *Manager) Analyze( - scriptLabel string, - scriptCode string, - callStack []string, - initiator HomescriptInitiator, + +func resolveFileContentsOfErrors( username string, - moduleStack []string, - moduleName string, - scopeInjections map[string]homescript.Value, -) []HmsError { - executor := &AnalyzerExecutor{ - Username: username, + mainModuleFilename string, + mainModuleCode string, + errors []HmsError, +) (map[string]string, error) { + fileContents := make(map[string]string) + + for _, err := range errors { + if err.Span.Filename == mainModuleFilename { + continue + } + + script, found, dbErr := database.GetUserHomescriptById(err.Span.Filename, username) + if dbErr != nil { + return nil, dbErr + } + if !found { + spew.Dump(err.DiagnosticError) + panic(fmt.Sprintf("Homescript with ID %s owned by user %s was not found", err.Span.Filename, username)) // TODO: no panic + } + + fileContents[err.Span.Filename] = script.Data.Code } - // Append the executor to the jobs - id := m.PushJob( - &Executor{SigTerm: make(chan int)}, - initiator, - make(chan uint64), - ) + return fileContents, nil +} - scopeAdditionsFinal := make(map[string]homescript.Value) +func (m *Manager) Analyze( + username string, + filename string, + code string, +) (map[string]ast.AnalyzedProgram, HmsRes, error) { + analyzedModules, diagnostics, syntaxErrors := homescript.Analyze( + homescript.InputProgram{ + Filename: filename, + ProgramText: code, + }, + analyzerScopeAdditions(), + newAnalyzerHost(username), + ) - for key, value := range scopeInjections { - _, exists := scopeAdditionsFinal[key] - if exists { - panic(fmt.Sprintf("Duplicate scope insertion key `%s`", key)) + errors := make([]HmsError, 0) + success := true + + if len(syntaxErrors) > 0 { + success = false + for _, syntax := range syntaxErrors { + errors = append(errors, HmsError{ + SyntaxError: &HmsSyntaxError{ + Message: syntax.Message, + }, + Span: syntax.Span, + }) } - // insert this value - scopeAdditionsFinal[key] = value } - if _, exists := scopeAdditionsFinal["context"]; !exists { - // Include `context` in order to prevent false errors during analysis - scopeAdditionsFinal["context"] = homescript.ValueBuiltinVariable{ - Callback: func(executor homescript.Executor, span hmsErrors.Span) (homescript.Value, *hmsErrors.Error) { - return homescript.ValueObject{IsDynamic: true, IsProtected: true, ObjFields: make(map[string]*homescript.Value)}, nil - }, + for _, d := range diagnostics { + if d.Level == diagnostic.DiagnosticLevelError { + success = false } + notesTemp := d.Notes + if d.Notes == nil { + notesTemp = make([]string, 0) + } + errors = append(errors, HmsError{ + DiagnosticError: &HmsDiagnosticError{ + Level: d.Level, + Message: d.Message, + Notes: notesTemp, + }, + Span: d.Span, + }) } - // Run the script - diagnostics, _, _ := homescript.Analyze( - executor, - scriptCode, - scopeAdditionsFinal, - moduleStack, - moduleName, - moduleName, + fileContents, err := resolveFileContentsOfErrors( + username, + filename, + code, + errors, ) - - // Remove the Job from the jobs list when this function ends - m.removeJob(id) - - diagnosticsErr := make([]HmsError, 0) - for _, diagnostic := range diagnostics { - diagnosticsErr = append(diagnosticsErr, HmsError{ - Kind: diagnostic.Kind.String(), - Message: diagnostic.Message, - Span: diagnostic.Span, - }) + if err != nil { + return nil, HmsRes{}, err } - return diagnosticsErr + return analyzedModules, HmsRes{ + Errors: errors, + FileContents: fileContents, + Success: success, + }, nil } func (m *Manager) AnalyzeById( - scriptId string, + id string, username string, - callStack []string, - initiator HomescriptInitiator, - scopeInjections map[string]homescript.Value, -) ([]HmsError, error) { - homescriptItem, hasBeenFound, err := database.GetUserHomescriptById(scriptId, username) +) (map[string]ast.AnalyzedProgram, HmsRes, error) { + hms, found, err := database.GetUserHomescriptById(id, username) if err != nil { - return nil, err + return nil, HmsRes{}, err } - if !hasBeenFound { - return nil, errors.New("invalid Homescript id: no data associated with id") + if !found { + panic(fmt.Sprintf("Homescript with ID %s owned by user %s was not found", id, username)) // TODO: no panic } - return m.Analyze( - scriptId, - homescriptItem.Data.Code, - append(callStack, scriptId), - initiator, - username, - make([]string, 0), - scriptId, - scopeInjections, - ), nil + + return m.Analyze(username, id, hms.Data.Code) } -// Executes arbitrary Homescript-code as a given user, returns the output and a possible error slice -// The `scriptLabel` argument is used internally to allow for better error-display -// The `excludedCalls` argument specifies which Homescripts may not be called by this Homescript in order to prevent recursion func (m *Manager) Run( username string, - scriptLabel string, - scriptCode string, - arguments map[string]string, - callStack []string, + filename *string, + code string, initiator HomescriptInitiator, - sigTerm chan int, - outputWriter io.Writer, + cancelCtx context.Context, + cancelCtxFunc context.CancelFunc, idChan *chan uint64, - scopeInjections map[string]homescript.Value, -) HmsExecRes { - // Is passed to the executor so that it can forward messages from its own `SigTerm` onto the `sigTermInternalPtr` - // Is also passed to `homescript.Run` so that the newly spawned interpreter uses the same channel - interpreterSigTerm := make(chan int) - - executor := &Executor{ - Username: username, - ScriptName: scriptLabel, - DryRun: false, - CallStack: callStack, - // This channel will receive the initial sigTerm which can quit the currently running callback function - // Additionally, the executor forwards the sigTerm to the interpreter which finally prevents any further node-evaluation - // => Required for host functions to quit expensive / slow operations (sleep), then invokes an interpreter sigTerm - SigTerm: sigTerm, - // The sigterm pointer is also passed into the executor - // => This pointer must ONLY be used internally, in this case is invoked from inside the `Executor` - sigTermInternalPtr: &interpreterSigTerm, - StartTime: time.Now(), - OutputWriter: outputWriter, - Initiator: initiator, + args map[string]string, + outputWriter io.Writer, +) (HmsRes, error) { + // TODO: handle arguments + + id := m.PushJob(username, initiator, cancelCtxFunc, filename) + defer m.removeJob(id) + + internalFilename := fmt.Sprintf("live@%d", id) // TODO: the @ symbol cannot be used in IDs? + if filename != nil { + internalFilename = *filename } - // Append the executor to the jobs - id := m.PushJob( - executor, - initiator, - make(chan uint64), - ) + modules, res, err := m.Analyze(username, internalFilename, code) + if err != nil { + return HmsRes{}, err + } + if !res.Success { + return res, nil + } - // Only send back the id if the channel exists + // send the id to the id channel (only if it exists) if idChan != nil { *idChan <- id } - valueArgs := make(map[string]homescript.Value) - for key, value := range arguments { - valueArgs[key] = homescript.ValueString{Value: value} - } - - scopeAdditionsFinal := make(map[string]homescript.Value) - for key, value := range scopeInjections { - _, exists := scopeAdditionsFinal[key] - if exists { - panic(fmt.Sprintf("Duplicate scope insertion key `%s`", key)) + log.Debug(fmt.Sprintf("Homescript '%s' of user '%s' is executing...", internalFilename, username)) + if i := homescript.Run( + CALL_STACK_LIMIT_SIZE, + modules, + internalFilename, + newInterpreterExecutor(username, outputWriter), + interpreterScopeAdditions(), + &cancelCtx, + ); i != nil { + span := errors.Span{} + + switch (*i).Kind() { + case value.TerminateInterruptKind: + termI := (*i).(value.TerminationInterrupt) + span = termI.Span + case value.RuntimeErrorInterruptKind: + runtimeI := (*i).(value.RuntimeErr) + span = runtimeI.Span + default: + panic("Another fatal interrupt was added without updating this code") } - // insert this value - scopeAdditionsFinal[key] = value - } - if _, exists := scopeAdditionsFinal["context"]; !exists { - // Include a default null `context` if ther is no other - scopeAdditionsFinal["context"] = homescript.ValueBuiltinVariable{ - Callback: func(executor homescript.Executor, span hmsErrors.Span) (homescript.Value, *hmsErrors.Error) { - return homescript.ValueNull{}, nil + errors := []HmsError{{ + RuntimeInterrupt: &HmsRuntimeInterrupt{ + Kind: (*i).Kind().String(), + Message: (*i).Message(), }, + Span: span, + }} + fileContents, err := resolveFileContentsOfErrors(username, internalFilename, code, errors) + if err != nil { + return HmsRes{}, err } - } - - // Run the script - returnValue, exitCode, rootScope, hmsErrors := homescript.Run( - executor, - &interpreterSigTerm, - scriptCode, - scopeAdditionsFinal, - valueArgs, - false, - 10000, - make([]string, 0), - scriptLabel, - scriptLabel, - ) - - wasTerminated := executor.WasTerminated - // Remove the Job from the jobs list when this function ends - m.removeJob(id) + log.Debug(fmt.Sprintf("Homescript '%s' of user '%s' failed: %s", internalFilename, username, errors[0])) - if len(hmsErrors) > 0 { - log.Debug(fmt.Sprintf("Homescript '%s' ran by user '%s' has terminated: %s", scriptLabel, username, hmsErrors[0].Message)) - } else if wasTerminated { - log.Debug(fmt.Sprintf("Homescript '%s' ran by user '%s' was terminated", scriptLabel, username)) - } else { - log.Debug(fmt.Sprintf("Homescript '%s' ran by user '%s' was executed successfully", scriptLabel, username)) + return HmsRes{ + Success: false, + Errors: errors, + FileContents: fileContents, + }, nil } - if returnValue == nil { - returnValue = homescript.ValueNull{} - } + log.Debug(fmt.Sprintf("Homescript '%s' of user '%s' executed successfully", internalFilename, username)) - // Process outcome - return HmsExecRes{ - ReturnValue: returnValue, - RootScope: rootScope, - ExitCode: exitCode, - WasTerminated: wasTerminated, - Errors: convertErrors(hmsErrors), - } + return HmsRes{Success: true, Errors: make([]HmsError, 0), FileContents: make(map[string]string)}, nil } // Executes a given Homescript from the database and returns its output, exit-code and possible error func (m *Manager) RunById( - scriptId string, + hmsId string, username string, - callStack []string, - arguments map[string]string, initiator HomescriptInitiator, - sigTerm chan int, - outputWriter io.Writer, + cancelCtx context.Context, + cancelCtxFunc context.CancelFunc, idChan *chan uint64, - scopeInjections map[string]homescript.Value, -) (HmsExecRes, error) { - homescriptItem, hasBeenFound, err := database.GetUserHomescriptById(scriptId, username) + args map[string]string, + outputWriter io.Writer, +) (HmsRes, error) { + script, found, err := database.GetUserHomescriptById(hmsId, username) if err != nil { - return HmsExecRes{}, err + return HmsRes{}, err } - if !hasBeenFound { - return HmsExecRes{}, errors.New("invalid Homescript id: no data associated with id") + if !found { + panic(fmt.Sprintf("Homescript with ID %s owned by user %s was not found", hmsId, username)) // TODO: no panic } + return m.Run( username, - scriptId, - homescriptItem.Data.Code, - arguments, - // The script's id is added to the callStack (exec blacklist) - append(callStack, scriptId), + &hmsId, + script.Data.Code, initiator, - sigTerm, - outputWriter, + cancelCtx, + cancelCtxFunc, idChan, - scopeInjections, - ), nil + args, + outputWriter, + ) } // Removes an arbitrary job from the job list @@ -350,7 +355,7 @@ func (m *Manager) removeJob(jobId uint64) bool { m.Lock.Lock() defer m.Lock.Unlock() for _, job := range m.Jobs { - if job.Id == jobId { + if job.JobId == jobId { success = true continue } @@ -365,7 +370,7 @@ func (m *Manager) GetJobById(jobId uint64) (Job, bool) { m.Lock.RLock() defer m.Lock.RUnlock() for _, job := range m.Jobs { - if job.Id == jobId { + if job.JobId == jobId { return job, true } } @@ -375,25 +380,14 @@ func (m *Manager) GetJobById(jobId uint64) (Job, bool) { // Terminates a job given its internal job ID // This method operates on all types of run-type // The returned boolean indicates whether a job was killed or not -func (m *Manager) Kill(jobId uint64, sigtermType HomescriptSigterm) bool { +func (m *Manager) Kill(jobId uint64) bool { m.Lock.Lock() defer m.Lock.Unlock() for _, job := range m.Jobs { - if job.Id == jobId { - job.Executor.InExpensiveBuiltin.Mutex.Lock() - if job.Executor.InExpensiveBuiltin.Value { - job.Executor.InExpensiveBuiltin.Mutex.Unlock() - // If the executor is currently handling an expensive builtin function, terminate it - log.Trace("Dispatching sigTerm to executor channel") - job.Executor.SigTerm <- int(sigtermType) - log.Trace("Successfully dispatched sigTerm to executor channel") - } else { - job.Executor.InExpensiveBuiltin.Mutex.Unlock() - // Otherwise, terminate the interpreter directly - log.Trace("Dispatching sigTerm to HMS interpreter channel") - *job.Executor.sigTermInternalPtr <- int(sigtermType) - log.Trace("Successfully dispatched sigTerm to HMS interpreter channel") - } + if job.JobId == jobId { + log.Trace("Dispatching sigTerm to HMS interpreter channel") + job.CancelCtx() + log.Trace("Successfully dispatched sigTerm to HMS interpreter channel") return true } } @@ -402,28 +396,21 @@ func (m *Manager) Kill(jobId uint64, sigtermType HomescriptSigterm) bool { // Terminates all jobs which are executing a given Homescript-ID / Homescript-label // The returned boolean indicates whether a job was killed or not -func (m *Manager) KillAllId(hmsId string, sigtermType HomescriptSigterm) (count uint64, success bool) { +func (m *Manager) KillAllId(hmsId string) (count uint64, success bool) { m.Lock.Lock() defer m.Lock.Unlock() for _, job := range m.Jobs { - // Only standalone scripts may be terminated (callstack validation) - if job.Executor.ScriptName == hmsId && len(job.Executor.CallStack) < 2 { - job.Executor.InExpensiveBuiltin.Mutex.Lock() - if job.Executor.InExpensiveBuiltin.Value { - // If the executor is currently handling an expensive builtin function, terminate it - log.Trace("Dispatching sigTerm to executor channel") - job.Executor.SigTerm <- int(sigtermType) - log.Trace("Successfully dispatched sigTerm to executor channel") - } else { - // Otherwise, terminate the interpreter directly - log.Trace("Dispatching sigTerm to HMS interpreter channel") - *job.Executor.sigTermInternalPtr <- int(sigtermType) - log.Trace("Successfully dispatched sigTerm to HMS interpreter channel") - } - job.Executor.InExpensiveBuiltin.Mutex.Unlock() - success = true - count++ + if job.HmsId == nil || *job.HmsId != hmsId { + continue } + + // Only standalone scripts may be terminated (callstack validation) | TODO: implement this + log.Trace("Dispatching sigTerm to HMS interpreter channel") + job.CancelCtx() + log.Trace("Successfully dispatched sigTerm to HMS interpreter channel") + + success = true + count++ } return count, success } @@ -443,17 +430,18 @@ func (m *Manager) GetUserDirectJobs(username string) []ApiJob { for _, job := range allJobs { // Skip any jobs which are not executed by the specified user - if job.Executor.Username != username { - continue - } - // Skip any indirect jobs - if len(job.Executor.CallStack) > 1 { + if job.Username != username { continue } + + // Skip any indirect jobs | TODO: do this + // if len(job.Executor.CallStack) > 1 { + // continue + // } + jobs = append(jobs, ApiJob{ - Id: job.Id, - Initiator: job.Initiator, - HomescriptId: job.Executor.ScriptName, + Jobid: job.JobId, + HmsId: job.HmsId, }) } return jobs diff --git a/core/homescript/scheduleRunner.go b/core/homescript/scheduleRunner.go index c0dcecb1..1bc825d5 100644 --- a/core/homescript/scheduleRunner.go +++ b/core/homescript/scheduleRunner.go @@ -2,14 +2,17 @@ package homescript import ( "bytes" + "context" "fmt" + "time" - "github.com/smarthome-go/homescript/v2/homescript" "github.com/smarthome-go/smarthome/core/database" "github.com/smarthome-go/smarthome/core/event" "github.com/smarthome-go/smarthome/core/hardware" ) +const SCHEDULE_MAXIMUM_RUNTIME = time.Minute * 10 + // Executes a given scheduler // If the user's schedulers are currently disabled // the job runner will still be executed and remove the current scheduler but without running the homescript @@ -70,24 +73,25 @@ func scheduleRunnerFunc(id uint) { log.Debug(fmt.Sprintf("Schedule '%s' (%d) is executing...", job.Data.Name, id)) switch job.Data.TargetMode { case database.ScheduleTargetModeCode: - res := HmsManager.Run( + ctx, cancel := context.WithTimeout(context.Background(), SCHEDULE_MAXIMUM_RUNTIME) + + res, err := HmsManager.Run( owner.Username, - fmt.Sprintf("%d.hms", id), + nil, job.Data.HomescriptCode, - make(map[string]string, 0), - make([]string, 0), - InitiatorScheduler, - make(chan int), - &bytes.Buffer{}, + InitiatorSchedule, + ctx, + cancel, nil, - make(map[string]homescript.Value), + nil, + &bytes.Buffer{}, ) - if len(res.Errors) > 0 { - log.Error("Executing schedule's Homescript failed: ", res.Errors[0].Message) + if err != nil { + log.Error("Executing schedule's Homescript failed: ", err.Error()) if _, err := Notify( owner.Username, "Schedule Failed", - fmt.Sprintf("Schedule '%s' failed due to Homescript error: %s", job.Data.Name, res.Errors[0].Message), + fmt.Sprintf("Schedule '%s' failed due to Homescript error: %s", job.Data.Name, err.Error()), NotificationLevelError, true, ); err != nil { @@ -96,21 +100,40 @@ func scheduleRunnerFunc(id uint) { } event.Error( "Schedule Failure", - fmt.Sprintf("Schedule '%d' failed. Error: %s", id, res.Errors[0].Message), + fmt.Sprintf("Schedule '%d' failed. Error: %s", id, err.Error()), + ) + return + } + if !res.Success { + log.Error("Executing schedule's Homescript failed: ", res.Errors[0]) + if _, err := Notify( + owner.Username, + "Schedule Failed", + fmt.Sprintf("Schedule '%s' failed due to Homescript error: %s", job.Data.Name, res.Errors[0]), + NotificationLevelError, + true, + ); err != nil { + log.Error("Failed to notify user: ", err.Error()) + return + } + event.Error( + "Schedule Failure", + fmt.Sprintf("Schedule '%d' failed. Error: %s", id, res.Errors[0]), ) return } case database.ScheduleTargetModeHMS: + ctx, cancel := context.WithTimeout(context.Background(), SCHEDULE_MAXIMUM_RUNTIME) + res, err := HmsManager.RunById( job.Data.HomescriptTargetId, owner.Username, - make([]string, 0), - make(map[string]string, 0), - InitiatorScheduler, - make(chan int), - &bytes.Buffer{}, + InitiatorSchedule, + ctx, + cancel, + nil, nil, - make(map[string]homescript.Value), + &bytes.Buffer{}, ) if err != nil { log.Error("Executing schedule's Homescript failed: ", err.Error()) @@ -130,12 +153,12 @@ func scheduleRunnerFunc(id uint) { ) return } - if len(res.Errors) > 0 { - log.Error("Executing schedule's Homescript failed: ", res.Errors[0].Message) + if !res.Success { + log.Error("Executing schedule's Homescript failed: ", res.Errors[0]) if _, err := Notify( owner.Username, "Schedule Failed", - fmt.Sprintf("Schedule '%s' failed due to Homescript execution error: %s", job.Data.Name, res.Errors[0].Message), + fmt.Sprintf("Schedule '%s' failed due to Homescript execution error: %s", job.Data.Name, res.Errors[0]), NotificationLevelError, true, ); err != nil { @@ -144,7 +167,7 @@ func scheduleRunnerFunc(id uint) { } event.Error( "Schedule Failure", - fmt.Sprintf("Schedule '%d' failed. Error: %s", id, res.Errors[0].Message), + fmt.Sprintf("Schedule '%d' failed. Error: %s", id, res.Errors[0]), ) return } diff --git a/core/shutdown.go b/core/shutdown.go index 19cacea1..260f47ce 100644 --- a/core/shutdown.go +++ b/core/shutdown.go @@ -35,7 +35,11 @@ func waitForHomescripts(ch *chan struct{}) { if idx > 0 { hmsList += ", " } - hmsList += "`" + hms.Executor.ScriptName + "`" + id := "" + if hms.HmsId != nil { + id = *hms.HmsId + } + hmsList += "`" + id + "`" } log.Trace(fmt.Sprintf("Waiting for %d Homescripts [%s] to finish execution...", len(homescript.HmsManager.GetJobList()), hmsList)) diff --git a/server/api/homescript.go b/server/api/homescript.go index c671d2b1..200524c6 100644 --- a/server/api/homescript.go +++ b/server/api/homescript.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -11,16 +12,13 @@ import ( "github.com/gorilla/mux" - hmsRuntime "github.com/smarthome-go/homescript/v2/homescript" "github.com/smarthome-go/smarthome/core/database" "github.com/smarthome-go/smarthome/core/homescript" "github.com/smarthome-go/smarthome/server/middleware" ) type HomescriptResponse struct { - Id string `json:"id"` Success bool `json:"success"` - Exitcode int `json:"exitCode"` Output string `json:"output"` FileContents map[string]string `json:"fileContents"` Errors []homescript.HmsError `json:"errors"` @@ -102,44 +100,27 @@ func RunHomescriptId(w http.ResponseWriter, r *http.Request) { initiator = homescript.InitiatorWidget } + ctx, cancel := context.WithCancel(context.Background()) + // Run the Homescript var outputBuffer bytes.Buffer - res := homescript.HmsManager.Run( + res, err := homescript.HmsManager.Run( username, - request.Id, + &request.Id, hmsData.Data.Code, - args, - make([]string, 0), initiator, - make(chan int), - &outputBuffer, + ctx, + cancel, nil, - make(map[string]hmsRuntime.Value), + args, + &outputBuffer, ) - output := outputBuffer.String() - - fileContents := make(map[string]string) - for _, errItem := range res.Errors { - if fileContents[errItem.Span.Filename] == "" { - script, found, err := database.GetUserHomescriptById(errItem.Span.Filename, username) - if err != nil { - w.WriteHeader(http.StatusServiceUnavailable) - Res(w, Response{Success: false, Message: fmt.Sprintf("could not retrieve error source Homescript (%s) from database", errItem.Span.Filename), Error: "database failure"}) - return - } - if found { - fileContents[errItem.Span.Filename] = script.Data.Code - } - } - } if err := json.NewEncoder(w).Encode( HomescriptResponse{ - Success: res.ExitCode == 0, - Id: request.Id, - Output: output, - Exitcode: res.ExitCode, - FileContents: fileContents, + Success: res.Success, + Output: outputBuffer.String(), + FileContents: res.FileContents, Errors: res.Errors, }); err != nil { log.Error(err.Error()) @@ -179,46 +160,23 @@ func LintHomescriptId(w http.ResponseWriter, r *http.Request) { return } // Lint the Homescript - diagnostics := homescript.HmsManager.Analyze( - request.Id, - hmsData.Data.Code, - make([]string, 0), - homescript.InitiatorAPI, + _, res, err := homescript.HmsManager.Analyze( username, - make([]string, 0), request.Id, - make(map[string]hmsRuntime.Value), + hmsData.Data.Code, ) - isSuccess := true - - for _, diagnostic := range diagnostics { - if diagnostic.Kind != "Warning" && diagnostic.Kind != "Info" { - isSuccess = false - break - } - } - fileContents := make(map[string]string) - for _, errItem := range diagnostics { - if fileContents[errItem.Span.Filename] == "" { - script, found, err := database.GetUserHomescriptById(errItem.Span.Filename, username) - if err != nil { - w.WriteHeader(http.StatusServiceUnavailable) - Res(w, Response{Success: false, Message: "could not retrieve error source Homescript from database", Error: "database failure"}) - return - } - if found { - fileContents[errItem.Span.Filename] = script.Data.Code - } - } + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + Res(w, Response{Success: false, Message: "Could not analyze Homescript", Error: "internal failure"}) + return } if err := json.NewEncoder(w).Encode( HomescriptResponse{ - Success: isSuccess, - Id: request.Id, - FileContents: fileContents, - Errors: diagnostics, + Success: res.Success, + FileContents: res.FileContents, + Errors: res.Errors, }); err != nil { log.Error(err.Error()) Res(w, Response{Success: false, Message: "could not encode response", Error: "could not encode response"}) @@ -247,47 +205,32 @@ func RunHomescriptString(w http.ResponseWriter, r *http.Request) { args[arg.Key] = arg.Value } + ctx, cancel := context.WithCancel(context.Background()) + // Run the Homescript var outputBuffer bytes.Buffer - res := homescript.HmsManager.Run( + res, err := homescript.HmsManager.Run( username, - "live", + nil, request.Code, - make(map[string]string), - make([]string, 0), homescript.InitiatorAPI, - make(chan int), - &outputBuffer, + ctx, + cancel, nil, - make(map[string]hmsRuntime.Value), + args, + &outputBuffer, ) output := outputBuffer.String() // if len(output) > 100_000 { // output = "Output too large" - // } - - fileContents := make(map[string]string) - for _, errItem := range res.Errors { - if fileContents[errItem.Span.Filename] == "" { - script, found, err := database.GetUserHomescriptById(errItem.Span.Filename, username) - if err != nil { - w.WriteHeader(http.StatusServiceUnavailable) - Res(w, Response{Success: false, Message: "could not retrieve error source Homescript from database", Error: "database failure"}) - return - } - if found { - fileContents[errItem.Span.Filename] = script.Data.Code - } - } - } + // } TODO: maybe re-introduce such a limit if err := json.NewEncoder(w).Encode( HomescriptResponse{ - Success: res.ExitCode == 0, + Success: res.Success, Output: output, - Exitcode: res.ExitCode, Errors: res.Errors, - FileContents: fileContents, + FileContents: res.FileContents, }); err != nil { log.Error(err.Error()) Res(w, Response{Success: false, Message: "could not encode response", Error: "could not encode response"}) @@ -314,45 +257,25 @@ func LintHomescriptString(w http.ResponseWriter, r *http.Request) { for _, arg := range request.Args { args[arg.Key] = arg.Value } + // Lint the Homescript - diagnostics := homescript.HmsManager.Analyze( - request.ModuleName, - request.Code, - make([]string, 0), - homescript.InitiatorAPI, + _, res, err := homescript.HmsManager.Analyze( username, - make([]string, 0), request.ModuleName, - make(map[string]hmsRuntime.Value), + request.Code, ) - isSuccess := true - for _, diagnostic := range diagnostics { - if diagnostic.Kind != "Warning" && diagnostic.Kind != "Info" { - isSuccess = false - break - } - } - fileContents := make(map[string]string) - for _, errItem := range diagnostics { - if fileContents[errItem.Span.Filename] == "" { - script, found, err := database.GetUserHomescriptById(errItem.Span.Filename, username) - if err != nil { - w.WriteHeader(http.StatusServiceUnavailable) - Res(w, Response{Success: false, Message: "could not retrieve error source Homescript from database", Error: "database failure"}) - return - } - if found { - fileContents[errItem.Span.Filename] = script.Data.Code - } - } + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + Res(w, Response{Success: false, Message: "failed to lint Homescript string", Error: "internal server error"}) + return } if err := json.NewEncoder(w).Encode( HomescriptResponse{ - Success: isSuccess, - Errors: diagnostics, - FileContents: fileContents, + Success: res.Success, + Errors: res.Errors, + FileContents: res.FileContents, }); err != nil { log.Error(err.Error()) Res(w, Response{Success: false, Message: "could not encode response", Error: "could not encode response"}) @@ -626,12 +549,12 @@ func KillJobById(w http.ResponseWriter, r *http.Request) { return } job, found := homescript.HmsManager.GetJobById(uint64(idInt)) - if !found || job.Executor.Username != username { + if !found || job.Username != username { w.WriteHeader(http.StatusUnprocessableEntity) Res(w, Response{Success: false, Message: "failed to kill Homescript job", Error: "invalid id provided"}) return } - if homescript.HmsManager.Kill(uint64(idInt), homescript.HmsSigtermCanceled) { + if homescript.HmsManager.Kill(uint64(idInt)) { Res(w, Response{Success: true, Message: "successfully killed Homescript job"}) } else { w.WriteHeader(http.StatusServiceUnavailable) @@ -665,7 +588,7 @@ func KillAllHMSIdJobs(w http.ResponseWriter, r *http.Request) { Res(w, Response{Success: false, Message: "could not kill all Homescript jobs", Error: "invalid Homescript id specified"}) return } - count, _ := homescript.HmsManager.KillAllId(id, homescript.HmsSigtermCanceled) + count, _ := homescript.HmsManager.KillAllId(id) Res(w, Response{Success: true, Message: fmt.Sprintf("successfully killed %d Homescript job(s)", count)}) } diff --git a/server/api/homescriptAsync.go b/server/api/homescriptAsync.go index 2edac6cf..0d41e76c 100644 --- a/server/api/homescriptAsync.go +++ b/server/api/homescriptAsync.go @@ -2,6 +2,7 @@ package api import ( "bufio" + "context" "fmt" "io" "net/http" @@ -10,8 +11,6 @@ import ( "time" "github.com/gorilla/websocket" - hmsRuntime "github.com/smarthome-go/homescript/v2/homescript" - "github.com/smarthome-go/smarthome/core/database" "github.com/smarthome-go/smarthome/core/homescript" "github.com/smarthome-go/smarthome/server/middleware" ) @@ -29,9 +28,9 @@ type HMSMessageTXOut struct { } type HMSMessageTXRes struct { Kind HMSMessageKindTX `json:"kind"` - Exitcode int `json:"exitCode"` Errors []homescript.HmsError `json:"errors"` FileContents map[string]string `json:"fileContents"` + Success bool `json:"success"` } type HMSMessageKindTX string @@ -123,21 +122,19 @@ func RunHomescriptByIDAsync(w http.ResponseWriter, r *http.Request) { } // Start running the code - res := make(chan homescript.HmsExecRes) - idChan := make(chan uint64) - - go func(writer io.Writer, results *chan homescript.HmsExecRes, id *chan uint64) { + res := make(chan homescript.HmsRes) + ctx, cancel := context.WithCancel(context.Background()) + go func(writer io.Writer, results *chan homescript.HmsRes, ctx context.Context, cancel context.CancelFunc) { res, err := homescript.HmsManager.RunById( - request.Payload, // The payload is the script-id in this case + request.Payload, username, - make([]string, 0), - args, homescript.InitiatorAPI, - make(chan int), - writer, - id, - make(map[string]hmsRuntime.Value), + ctx, + cancel, + nil, + args, + outWriter, ) if err != nil { wsMutex.Lock() @@ -154,9 +151,7 @@ func RunHomescriptByIDAsync(w http.ResponseWriter, r *http.Request) { outWriter.Close() *results <- res - }(outWriter, &res, &idChan) - - id := <-idChan + }(outWriter, &res, ctx, cancel) go func() { // Check if the script should be killed @@ -189,10 +184,7 @@ func RunHomescriptByIDAsync(w http.ResponseWriter, r *http.Request) { } // Kill the Homescript log.Trace("Killing script via Websocket") - if !homescript.HmsManager.Kill(id, homescript.HmsSigtermCanceled) { - // Either the id is invalid or the script is not running anymore - return - } + cancel() log.Trace("Killed script via Websocket") }() @@ -232,27 +224,11 @@ outer: return } - fileContents := make(map[string]string) - for _, errItem := range res.Errors { - if fileContents[errItem.Span.Filename] == "" { - script, _, err := database.GetUserHomescriptById(errItem.Span.Filename, username) - if err != nil { - if err := ws.WriteJSON(HMSMessageTXErr{ - Kind: MessageKindErr, - Message: fmt.Sprintf("cannot get homescript for error location: %s", err.Error()), - }); err != nil { - return - } - } - fileContents[errItem.Span.Filename] = script.Data.Code - } - } - if err := ws.WriteJSON(HMSMessageTXRes{ Kind: MessageKindResults, - Exitcode: res.ExitCode, Errors: res.Errors, - FileContents: fileContents, + FileContents: res.FileContents, + Success: res.Success, }); err != nil { return } diff --git a/web/package-lock.json b/web/package-lock.json index 326853fb..40d44a20 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -38,7 +38,7 @@ "chart.js": "^3.9.1", "chartjs-adapter-date-fns": "^2.0.0", "codemirror": "^6.0.1", - "codemirror-lang-homescript": "^0.2.0", + "codemirror-lang-homescript": "^0.3.0", "date-fns": "^2.29.3", "marked": "^5.0.2", "material-icons": "^1.12.0", @@ -68,9 +68,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz", - "integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.5.tgz", + "integrity": "sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA==", "dependencies": { "regenerator-runtime": "^0.13.11" }, @@ -79,9 +79,9 @@ } }, "node_modules/@codemirror/autocomplete": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.7.1.tgz", - "integrity": "sha512-hSxf9S0uB+GV+gBsjY1FZNo53e1FFdzPceRfCfD1gWOnV6o21GfB5J5Wg9G/4h76XZMPrF0A6OCK/Rz5+V1egg==", + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.8.0.tgz", + "integrity": "sha512-nTimZYnTYaZ5skAt+zlk8BD41GvjpWgtDni2K+BritA7Ed9A0aJWwo1ohTvwUEfHfhIVtcFSLEddVPkegw8C/Q==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -107,9 +107,9 @@ } }, "node_modules/@codemirror/language": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.7.0.tgz", - "integrity": "sha512-4SMwe6Fwn57klCUsVN0y4/h/iWT+XIXFEmop2lIHHuWO0ubjCrF3suqSZLyOQlznxkNnNbOOfKe5HQbQGCAmTg==", + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.8.0.tgz", + "integrity": "sha512-r1paAyWOZkfY0RaYEZj3Kul+MiQTEbDvYqf8gPGaRvNneHXCmfSaAVFjwRUPlgxS8yflMxw2CTu6uCMp8R8A2g==", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", @@ -120,9 +120,9 @@ } }, "node_modules/@codemirror/lint": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.2.1.tgz", - "integrity": "sha512-y1muai5U/uUPAGRyHMx9mHuHLypPcHWxzlZGknp/U5Mdb5Ol8Q5ZLp67UqyTbNFJJ3unVxZ8iX3g1fMN79S1JQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.2.2.tgz", + "integrity": "sha512-kHGuynBHjqinp1Bx25D2hgH8a6Fh1m9rSmZFzBVTqPIXDIcZ6j3VI67DY8USGYpGrjrJys9R52eLxtfERGNozg==", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", @@ -130,9 +130,9 @@ } }, "node_modules/@codemirror/search": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.4.0.tgz", - "integrity": "sha512-zMDgaBXah+nMLK2dHz9GdCnGbQu+oaGRXS1qviqNZkvOCv/whp5XZFyoikLp/23PM9RBcbuKUUISUmQHM1eRHw==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.0.tgz", + "integrity": "sha512-64/M40YeJPToKvGO6p3fijo2vwUEj4nACEAXElCaYQ50HrXSvRaK+NHEhSh73WFBGdvIdhrV+lL9PdJy2RfCYA==", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", @@ -145,9 +145,9 @@ "integrity": "sha512-RupHSZ8+OjNT38zU9fKH2sv+Dnlr8Eb8sl4NOnnqz95mCFTZUaiRP8Xv5MeeaG0px2b8Bnfe7YGwCV3nsBhbuw==" }, "node_modules/@codemirror/view": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.12.0.tgz", - "integrity": "sha512-xNHvbJBc2v8JuEcIGOck6EUGShpP+TYGCEMVEVQMYxbFXfMhYnoF3znxB/2GgeKR0nrxBs+nhBupiTYQqCp2kw==", + "version": "6.13.2", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.13.2.tgz", + "integrity": "sha512-XA/jUuu1H+eTja49654QkrQwx2CuDMdjciHcdqyasfTVo4HRlvj87rD/Qmm4HfnhwX8234FQSSA8HxEzxihX/Q==", "dependencies": { "@codemirror/state": "^6.1.4", "style-mod": "^4.0.0", @@ -514,9 +514,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.41.0.tgz", - "integrity": "sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA==", + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.43.0.tgz", + "integrity": "sha512-s2UHCoiXfxMvmfzqoN+vrQ84ahUSYde9qNO1MdxmoEhyHWsfmwOpFlwYV+ePJEVc7gFnATGUi376WowX1N7tFg==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -528,9 +528,9 @@ "integrity": "sha512-LJF1ala1/u+wXZmESFqIk08FA9yGX4/uAAleCHmXUMgEjvNAYFHUQQ7eK5hQQoBOwh99cU5suTrqYqEkgzwzPA==" }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", - "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", + "integrity": "sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==", "dev": true, "dependencies": { "@humanwhocodes/object-schema": "^1.2.1", @@ -561,22 +561,22 @@ "dev": true }, "node_modules/@lezer/common": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.2.tgz", - "integrity": "sha512-SVgiGtMnMnW3ActR8SXgsDhw7a0w0ChHSYAyAUxxrOiJ1OqYWEKk/xJd84tTSPo1mo6DXLObAJALNnd0Hrv7Ng==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.3.tgz", + "integrity": "sha512-JH4wAXCgUOcCGNekQPLhVeUtIqjH0yPBs7vvUdSjyQama9618IOKFJwkv2kcqdhF0my8hQEgCTEJU0GIgnahvA==" }, "node_modules/@lezer/highlight": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.1.4.tgz", - "integrity": "sha512-IECkFmw2l7sFcYXrV8iT9GeY4W0fU4CxX0WMwhmhMIVjoDdD1Hr6q3G2NqVtLg/yVe5n7i4menG3tJ2r4eCrPQ==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.1.6.tgz", + "integrity": "sha512-cmSJYa2us+r3SePpRCjN5ymCqCPv+zyXmDl0ciWtVaNiORT/MxM7ZgOMQZADD0o51qOaOg24qc/zBViOIwAjJg==", "dependencies": { "@lezer/common": "^1.0.0" } }, "node_modules/@lezer/lr": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.3.4.tgz", - "integrity": "sha512-7o+e4og/QoC/6btozDPJqnzBhUaD1fMfmvnEKQO1wRRiTse1WxaJ3OMEXZJnkgT6HCcTVOctSoXK9jGJw2oe9g==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.3.6.tgz", + "integrity": "sha512-IDhcWjfxwWACnatUi0GzWBCbochfqxo3LZZlS27LbJh8RVYYXXyR5Ck9659IhkWkhSW/kZlaaiJpUO+YZTUK+Q==", "dependencies": { "@lezer/common": "^1.0.0" } @@ -1615,9 +1615,9 @@ "integrity": "sha512-pYrtLtOwku/7r1i9AMONsJMVYAtk3hzOfiGNekhtq5tYBGA7unMve8RvUclKLMT3PrihvJqUmzsRGh0RP84hKg==" }, "node_modules/@types/json-schema": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", - "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", + "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", "dev": true }, "node_modules/@types/marked": { @@ -1627,9 +1627,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.2.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.2.3.tgz", - "integrity": "sha512-pg9d0yC4rVNWQzX8U7xb4olIOFuuVL9za3bzMT2pu2SU0SNEi66i2qrvhE2qt0HvkhuCaWJu7pLNOt/Pj8BIrw==", + "version": "20.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.1.tgz", + "integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==", "dev": true }, "node_modules/@types/pug": { @@ -1664,15 +1664,15 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.59.7", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.7.tgz", - "integrity": "sha512-BL+jYxUFIbuYwy+4fF86k5vdT9lT0CNJ6HtwrIvGh0PhH8s0yy5rjaKH2fDCrz5ITHy07WCzVGNvAmjJh4IJFA==", + "version": "5.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.60.0.tgz", + "integrity": "sha512-78B+anHLF1TI8Jn/cD0Q00TBYdMgjdOn980JfAVa9yw5sop8nyTfVOQAv6LWywkOGLclDBtv5z3oxN4w7jxyNg==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.59.7", - "@typescript-eslint/type-utils": "5.59.7", - "@typescript-eslint/utils": "5.59.7", + "@typescript-eslint/scope-manager": "5.60.0", + "@typescript-eslint/type-utils": "5.60.0", + "@typescript-eslint/utils": "5.60.0", "debug": "^4.3.4", "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", @@ -1698,15 +1698,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.59.7", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.7.tgz", - "integrity": "sha512-VhpsIEuq/8i5SF+mPg9jSdIwgMBBp0z9XqjiEay+81PYLJuroN+ET1hM5IhkiYMJd9MkTz8iJLt7aaGAgzWUbQ==", + "version": "5.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.60.0.tgz", + "integrity": "sha512-jBONcBsDJ9UoTWrARkRRCgDz6wUggmH5RpQVlt7BimSwaTkTjwypGzKORXbR4/2Hqjk9hgwlon2rVQAjWNpkyQ==", "dev": true, "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "5.59.7", - "@typescript-eslint/types": "5.59.7", - "@typescript-eslint/typescript-estree": "5.59.7", + "@typescript-eslint/scope-manager": "5.60.0", + "@typescript-eslint/types": "5.60.0", + "@typescript-eslint/typescript-estree": "5.60.0", "debug": "^4.3.4" }, "engines": { @@ -1726,13 +1726,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.59.7", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.7.tgz", - "integrity": "sha512-FL6hkYWK9zBGdxT2wWEd2W8ocXMu3K94i3gvMrjXpx+koFYdYV7KprKfirpgY34vTGzEPPuKoERpP8kD5h7vZQ==", + "version": "5.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.60.0.tgz", + "integrity": "sha512-hakuzcxPwXi2ihf9WQu1BbRj1e/Pd8ZZwVTG9kfbxAMZstKz8/9OoexIwnmLzShtsdap5U/CoQGRCWlSuPbYxQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.59.7", - "@typescript-eslint/visitor-keys": "5.59.7" + "@typescript-eslint/types": "5.60.0", + "@typescript-eslint/visitor-keys": "5.60.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -1743,13 +1743,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.59.7", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.7.tgz", - "integrity": "sha512-ozuz/GILuYG7osdY5O5yg0QxXUAEoI4Go3Do5xeu+ERH9PorHBPSdvD3Tjp2NN2bNLh1NJQSsQu2TPu/Ly+HaQ==", + "version": "5.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.60.0.tgz", + "integrity": "sha512-X7NsRQddORMYRFH7FWo6sA9Y/zbJ8s1x1RIAtnlj6YprbToTiQnM6vxcMu7iYhdunmoC0rUWlca13D5DVHkK2g==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "5.59.7", - "@typescript-eslint/utils": "5.59.7", + "@typescript-eslint/typescript-estree": "5.60.0", + "@typescript-eslint/utils": "5.60.0", "debug": "^4.3.4", "tsutils": "^3.21.0" }, @@ -1770,9 +1770,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "5.59.7", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.7.tgz", - "integrity": "sha512-UnVS2MRRg6p7xOSATscWkKjlf/NDKuqo5TdbWck6rIRZbmKpVNTLALzNvcjIfHBE7736kZOFc/4Z3VcZwuOM/A==", + "version": "5.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.60.0.tgz", + "integrity": "sha512-ascOuoCpNZBccFVNJRSC6rPq4EmJ2NkuoKnd6LDNyAQmdDnziAtxbCGWCbefG1CNzmDvd05zO36AmB7H8RzKPA==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -1783,13 +1783,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.59.7", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.7.tgz", - "integrity": "sha512-4A1NtZ1I3wMN2UGDkU9HMBL+TIQfbrh4uS0WDMMpf3xMRursDbqEf1ahh6vAAe3mObt8k3ZATnezwG4pdtWuUQ==", + "version": "5.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.60.0.tgz", + "integrity": "sha512-R43thAuwarC99SnvrBmh26tc7F6sPa2B3evkXp/8q954kYL6Ro56AwASYWtEEi+4j09GbiNAHqYwNNZuNlARGQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.59.7", - "@typescript-eslint/visitor-keys": "5.59.7", + "@typescript-eslint/types": "5.60.0", + "@typescript-eslint/visitor-keys": "5.60.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1810,17 +1810,17 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "5.59.7", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.7.tgz", - "integrity": "sha512-yCX9WpdQKaLufz5luG4aJbOpdXf/fjwGMcLFXZVPUz3QqLirG5QcwwnIHNf8cjLjxK4qtzTO8udUtMQSAToQnQ==", + "version": "5.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.60.0.tgz", + "integrity": "sha512-ba51uMqDtfLQ5+xHtwlO84vkdjrqNzOnqrnwbMHMRY8Tqeme8C2Q8Fc7LajfGR+e3/4LoYiWXUM6BpIIbHJ4hQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.59.7", - "@typescript-eslint/types": "5.59.7", - "@typescript-eslint/typescript-estree": "5.59.7", + "@typescript-eslint/scope-manager": "5.60.0", + "@typescript-eslint/types": "5.60.0", + "@typescript-eslint/typescript-estree": "5.60.0", "eslint-scope": "^5.1.1", "semver": "^7.3.7" }, @@ -1836,12 +1836,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.59.7", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.7.tgz", - "integrity": "sha512-tyN+X2jvMslUszIiYbF0ZleP+RqQsFVpGrKI6e0Eet1w8WmhsAtmzaqm8oM8WJQ1ysLwhnsK/4hYHJjOgJVfQQ==", + "version": "5.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.60.0.tgz", + "integrity": "sha512-wm9Uz71SbCyhUKgcaPRauBdTegUyY/ZWl8gLwD/i/ybJqscrrdVSFImpvUz16BLPChIeKBK5Fa9s6KDQjsjyWw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.59.7", + "@typescript-eslint/types": "5.60.0", "eslint-visitor-keys": "^3.3.0" }, "engines": { @@ -1853,9 +1853,9 @@ } }, "node_modules/acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz", + "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -2042,9 +2042,9 @@ } }, "node_modules/browserslist": { - "version": "4.21.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz", - "integrity": "sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==", + "version": "4.21.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.9.tgz", + "integrity": "sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==", "dev": true, "funding": [ { @@ -2054,13 +2054,17 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "caniuse-lite": "^1.0.30001449", - "electron-to-chromium": "^1.4.284", - "node-releases": "^2.0.8", - "update-browserslist-db": "^1.0.10" + "caniuse-lite": "^1.0.30001503", + "electron-to-chromium": "^1.4.431", + "node-releases": "^2.0.12", + "update-browserslist-db": "^1.0.11" }, "bin": { "browserslist": "cli.js" @@ -2101,9 +2105,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001489", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001489.tgz", - "integrity": "sha512-x1mgZEXK8jHIfAxm+xgdpHpk50IN3z3q3zP261/WS+uvePxW8izXuCu6AHz0lkuYTlATDehiZ/tNyYBdSQsOUQ==", + "version": "1.0.30001505", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001505.tgz", + "integrity": "sha512-jaAOR5zVtxHfL0NjZyflVTtXm3D3J9P15zSJ7HmQF8dSKGA6tqzQq+0ZI3xkjyQj46I4/M0K2GbMpcAFOcbr3A==", "dev": true, "funding": [ { @@ -2222,10 +2226,11 @@ } }, "node_modules/codemirror-lang-homescript": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/codemirror-lang-homescript/-/codemirror-lang-homescript-0.2.0.tgz", - "integrity": "sha512-jB/zKUx9C2584sxYN0EF4TN3p2/Noq4Z1aKDYOZyltj2ELHVhl+G1I4rnOv33TV8YJxJ2qhZSy+Q/Tw5mCeouQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/codemirror-lang-homescript/-/codemirror-lang-homescript-0.3.0.tgz", + "integrity": "sha512-2w/8vgNirc8AZhB+ArTLKKFT34zJyv3/46ofxE0qc9zAbFg6MbBrpTAcNfQbpfgx6ojnckGZUhne1v1pTuP7Ng==", "dependencies": { + "@codemirror/autocomplete": "^6.8.0", "@codemirror/language": "^6.2.1", "@lezer/highlight": "^1.1.2", "@lezer/lr": "^1.2.3" @@ -2471,9 +2476,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.405", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.405.tgz", - "integrity": "sha512-JdDgnwU69FMZURoesf9gNOej2Cms1XJFfLk24y1IBtnAdhTcJY/mXnokmpmxHN59PcykBP4bgUU98vLY44Lhuw==", + "version": "1.4.435", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.435.tgz", + "integrity": "sha512-B0CBWVFhvoQCW/XtjRzgrmqcgVWg6RXOEM/dK59+wFV93BFGR6AeNKc4OyhM+T3IhJaOOG8o/V+33Y2mwJWtzw==", "dev": true }, "node_modules/emoji-regex": { @@ -2963,16 +2968,16 @@ } }, "node_modules/eslint": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.41.0.tgz", - "integrity": "sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==", + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.43.0.tgz", + "integrity": "sha512-aaCpf2JqqKesMFGgmRPessmVKjcGXqdlAYLLC3THM8t5nBRZRQ+st5WM/hoJXkdioEXLLbXgclUpM0TXo5HX5Q==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", "@eslint/eslintrc": "^2.0.3", - "@eslint/js": "8.41.0", - "@humanwhocodes/config-array": "^0.11.8", + "@eslint/js": "8.43.0", + "@humanwhocodes/config-array": "^0.11.10", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "ajv": "^6.10.0", @@ -4106,9 +4111,9 @@ } }, "node_modules/marked": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-5.0.2.tgz", - "integrity": "sha512-TXksm9GwqXCRNbFUZmMtqNLvy3K2cQHuWmyBDLOrY1e6i9UvZpOTJXoz7fBjYkJkaUFzV9hBFxMuZSyQt8R6KQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-5.1.0.tgz", + "integrity": "sha512-z3/nBe7aTI8JDszlYLk7dDVNpngjw0o1ZJtrA9kIfkkHcIF+xH7mO23aISl4WxP83elU+MFROgahqdpd05lMEQ==", "bin": { "marked": "bin/marked.js" }, @@ -4117,9 +4122,9 @@ } }, "node_modules/material-icons": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/material-icons/-/material-icons-1.13.6.tgz", - "integrity": "sha512-I9NjTMwdmq7QECY92ReLqbhWtUZmjlE0eX9UbIHFsSbT7wRp3aj/3ZbuHLFb9w96HG0S4pWbVQjwTQLxyfYyEg==" + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/material-icons/-/material-icons-1.13.8.tgz", + "integrity": "sha512-vnLGXKa/AwFUxUgkiX39EpYVFttPhDQcKdVylIqmUUqz+Eo/O9A3BkdPCU3/G5cJOTezHi5B/b8sEpKYgUNAwQ==" }, "node_modules/memorystream": { "version": "0.3.1", @@ -4255,9 +4260,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.11.tgz", - "integrity": "sha512-+M0PwXeU80kRohZ3aT4J/OnR+l9/KD2nVLNNoRgFtnf+umQVFdGBAO2N8+nCnEi0xlh/Wk3zOGC+vNNx+uM79Q==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.12.tgz", + "integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==", "dev": true }, "node_modules/normalize-package-data": { @@ -4678,9 +4683,9 @@ } }, "node_modules/postcss": { - "version": "8.4.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz", - "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", + "version": "8.4.24", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz", + "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==", "dev": true, "funding": [ { @@ -5678,9 +5683,9 @@ } }, "node_modules/sass": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.62.1.tgz", - "integrity": "sha512-NHpxIzN29MXvWiuswfc1W3I0N8SXBd8UR26WntmDlRYf0bSADnwnOjsyMZ3lMezSlArD33Vs3YFhp7dWvL770A==", + "version": "1.63.4", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.63.4.tgz", + "integrity": "sha512-Sx/+weUmK+oiIlI+9sdD0wZHsqpbgQg8wSwSnGBjwb5GwqFhYNwwnI+UWZtLjKvKyFlKkatRK235qQ3mokyPoQ==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -5695,9 +5700,9 @@ } }, "node_modules/semver": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", - "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz", + "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -6025,23 +6030,23 @@ } }, "node_modules/svelte": { - "version": "3.59.1", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.59.1.tgz", - "integrity": "sha512-pKj8fEBmqf6mq3/NfrB9SLtcJcUvjYSWyePlfCqN9gujLB25RitWK8PvFzlwim6hD/We35KbPlRteuA6rnPGcQ==", + "version": "3.59.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.59.2.tgz", + "integrity": "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==", "engines": { "node": ">= 8" } }, "node_modules/svelte-hmr": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.1.tgz", - "integrity": "sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.2.tgz", + "integrity": "sha512-q/bAruCvFLwvNbeE1x3n37TYFb3mTBJ6TrCq6p2CoFbSTNhDE9oAtEfpy+wmc9So8AG0Tja+X0/mJzX9tSfvIg==", "dev": true, "engines": { "node": "^12.20 || ^14.13.1 || >= 16" }, "peerDependencies": { - "svelte": ">=3.19.0" + "svelte": "^3.19.0 || ^4.0.0-next.0" } }, "node_modules/svelte-preprocess": { @@ -6165,9 +6170,9 @@ "dev": true }, "node_modules/tslib": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.2.tgz", - "integrity": "sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==" + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz", + "integrity": "sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -6337,9 +6342,9 @@ } }, "node_modules/vite": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.6.tgz", - "integrity": "sha512-nTXTxYVvaQNLoW5BQ8PNNQ3lPia57gzsQU/Khv+JvzKPku8kNZL6NMUR/qwXhMG6E+g1idqEPanomJ+VZgixEg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz", + "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==", "dev": true, "dependencies": { "esbuild": "^0.15.9", @@ -6400,9 +6405,9 @@ } }, "node_modules/w3c-keyname": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.7.tgz", - "integrity": "sha512-XB8aa62d4rrVfoZYQaYNy3fy+z4nrfy2ooea3/0BnBzXW0tSdZ+lRgjzBZhk0La0H6h8fVyYCxx/qkQcAIuvfg==" + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" }, "node_modules/webidl-conversions": { "version": "3.0.1", diff --git a/web/package.json b/web/package.json index 9f18f09b..35154cf6 100644 --- a/web/package.json +++ b/web/package.json @@ -77,7 +77,7 @@ "chart.js": "^3.9.1", "chartjs-adapter-date-fns": "^2.0.0", "codemirror": "^6.0.1", - "codemirror-lang-homescript": "^0.2.0", + "codemirror-lang-homescript": "^0.3.0", "date-fns": "^2.29.3", "marked": "^5.0.2", "material-icons": "^1.12.0", diff --git a/web/src/components/Homescript/ExecutionResultPopup/Terminal.svelte b/web/src/components/Homescript/ExecutionResultPopup/Terminal.svelte index c15efb14..238a18d6 100644 --- a/web/src/components/Homescript/ExecutionResultPopup/Terminal.svelte +++ b/web/src/components/Homescript/ExecutionResultPopup/Terminal.svelte @@ -9,6 +9,15 @@ // Terminal output export let output: string + function replaceWithHTMLCharacterCodes(input: string): string { + return input + .replaceAll('\t', ' ') + .replaceAll(' ', ' ') + .replaceAll('\n', ' ') + .replaceAll('<', '<') + .replaceAll('>', '>') + } + function errToHtml(err: homescriptError, data: hmsResWrapper): string { let code = data.code @@ -16,6 +25,53 @@ code = data.fileContents[err.span.filename] } + let color = 'red' + let kind = 'error: unknown' + let message = 'error: unknown' + let notes = '' + + if (err.syntaxError !== null) { + kind = 'SyntaxError' + message = err.syntaxError.message + } else if (err.diagnosticError !== null) { + message = err.diagnosticError.message + notes = err.diagnosticError.notes + .map(n => `- note: ${n}`) + .join('') + + switch (err.diagnosticError.kind) { + case 0: + kind = 'Hint' + color = 'purple' + break + case 1: + kind = 'Info' + color = 'cyan' + break + case 2: + kind = 'Warning' + color = 'yellow' + break + case 3: + kind = 'Error' + color = 'red' + break + } + } else { + kind = err.runtimeError.kind + message = err.runtimeError.message + } + + // if there is no useful span, do not try to include it + if ( + err.span.start.line === 0 && + err.span.start.column === 0 && + err.span.end.line === 0 && + err.span.end.column == 0 + ) { + return `${kind} in ${err.span.filename}
${message}
${notes}` + } + const lines = code.split('\n') let line1 = '' @@ -43,16 +99,8 @@ .replaceAll('\t', ' ') .replaceAll(' ', ' ')}` - let color = 'red' - if (err.kind == 'Warning') { - color = 'yellow' - } - if (err.kind == 'Info') { - color = 'cyan' - } - let rawMarker = '^' - if (err.kind === 'Warning' || err.kind === 'Info') { + if (color == 'yellow' || color === 'cyan' || color === 'purple') { rawMarker = '~' } if (err.span.start.line === err.span.end.line) { @@ -66,21 +114,21 @@ )}${rawMarker}` return ( - `${err.kind} at ${err.span.filename}:${err.span.start.line}:${err.span.start.column}` + - `
${line1}
${line2}
${marker}${line3}

${err.message + `${kind} at ${err.span.filename}:${err.span.start.line}:${err.span.start.column}` + + `
${line1}
${line2}
${marker}${line3}

${message .replaceAll(' ', ' ') - .replaceAll('\n', '
')}
` + .replaceAll('\n', '
')}

${notes}` ) }
{#if output.length > 0} - {@html output.replaceAll('\n', '
').replaceAll(' ', ' ')} + {@html replaceWithHTMLCharacterCodes(output)}
{/if} {#if data !== undefined} - {#if data.exitCode !== 0 && output.length > 0} + {#if !data.success}
{/if} {#each data.errors as err} @@ -90,11 +138,15 @@ {/each} {#if data.modeRun} - Homescript stopped with exit code - {data.exitCode} + {#if data.success} + Homescript executed successfully + {:else} + Homescript failed with interrupt + {/if} + {:else if data.success} + Analyzer detected no issues {:else} - Lint finished with exit code - {data.exitCode} + Analyzer detected issues {/if} {:else} @@ -123,7 +175,7 @@ } .cyan { - color: #4cd1e0; + color: #4ad0df; } .gray { diff --git a/web/src/components/Homescript/HmsEditor/HmsEditor.svelte b/web/src/components/Homescript/HmsEditor/HmsEditor.svelte index 56e81198..dce9b866 100644 --- a/web/src/components/Homescript/HmsEditor/HmsEditor.svelte +++ b/web/src/components/Homescript/HmsEditor/HmsEditor.svelte @@ -2,10 +2,12 @@ import { EditorView, basicSetup } from 'codemirror' import { EditorState } from '@codemirror/state' import { indentWithTab } from '@codemirror/commands' - import { keymap } from '@codemirror/view' + import { keymap, drawSelection, dropCursor } from '@codemirror/view' import { linter, lintGutter, type Diagnostic } from '@codemirror/lint' import { createEventDispatcher, onMount } from 'svelte' - import { Homescript } from 'codemirror-lang-homescript' + import { indentUnit } from '@codemirror/language' + //import { Homescript } from 'codemirror-lang-homescript' + import {Homescript} from './index' import { oneDark } from './oneDark' import { lintHomescriptCode } from '../../../homescript' import { createSnackbar } from '../../../global' @@ -55,6 +57,8 @@ // eslint-disable-next-line no-undef let timer: NodeJS.Timeout + // TODO: check filenames + syntaax erorrs in imported module + const HMSlinter = linter(async () => { let diagnostics: Diagnostic[] = [] @@ -62,11 +66,37 @@ const result = await lintHomescriptCode(code, [], moduleName) diagnostics = result.errors.map(e => { let severity = 'error' - if (e.kind === 'Warning') { - severity = 'warning' - } else if (e.kind === 'Info') { - severity = 'info' + let message = 'error: unknown' + let kind = 'error: unknown' + + // everything except diagnostics will be a standard `error` + if (e.syntaxError !== null) { + message = e.syntaxError.message + kind = 'SyntaxError' + } else if (e.diagnosticError !== null) { + message = e.diagnosticError.message + switch (e.diagnosticError.kind) { + case 0: + kind = 'Hint' + severity = 'hint' + break + case 1: + kind = 'Info' + severity = 'info' + break + case 2: + kind = 'Warning' + severity = 'warning' + break + case 3: + kind = 'Error' + severity = 'error' + break + } + } else if (e.runtimeError) { + throw 'A runtime error cannot occur during analysis' } + return Object.create({ from: e.span.start.index, to: @@ -74,7 +104,7 @@ ? e.span.end.index + 1 : e.span.end.index, severity: severity, - message: `${e.kind}: ${e.message}`, + message: `${kind}: ${message}`, source: 'Homescript analyzer', }) }) @@ -94,10 +124,14 @@ dispatch('update', code) } }) + editor = new EditorView({ state: EditorState.create({ extensions: [ basicSetup, + drawSelection(), + dropCursor(), + indentUnit.of(' '), keymap.of([indentWithTab]), Homescript(), oneDark, @@ -124,15 +158,6 @@ }), parent: editorDiv, }) - - /* - editor.dispatch( - editor.state.changeByRange((range) => ({ - changes: [{ from: range.from, insert: "switch('id', on)" }], - range: EditorSelection.range(range.from + 2, range.to + 2), - })) - ); - */ }) @@ -153,5 +178,13 @@ .cm-lint-marker-error { content: url('data:image/svg+xml,') !important; } + + .ΝΌ4 .cm-line ::selection { + background-color: rgba(255, 255, 255, 0.2) !important; + } + + .cm-selectionMatch { + background-color: rgba(100, 255, 0, 0.05) !important; + } } diff --git a/web/src/components/Homescript/HmsEditor/oneDark.ts b/web/src/components/Homescript/HmsEditor/oneDark.ts index 730dfedd..f445110d 100644 --- a/web/src/components/Homescript/HmsEditor/oneDark.ts +++ b/web/src/components/Homescript/HmsEditor/oneDark.ts @@ -100,6 +100,7 @@ export const oneDarkTheme = EditorView.theme({ /// The highlighting style for code in the One Dark theme. export const oneDarkHighlightStyle = HighlightStyle.define([ + { tag: t.namespace, color: yellow }, { tag: t.keyword, color: purple }, { tag: t.className, color: yellow }, { tag: [t.variableName, t.operator], color: fg }, diff --git a/web/src/homescript.ts b/web/src/homescript.ts index 66c3748e..e8fef14d 100644 --- a/web/src/homescript.ts +++ b/web/src/homescript.ts @@ -47,19 +47,33 @@ export interface homescriptResponseWrapper { } export interface homescriptResponse { - success: boolean id: string - exitCode: number - message: string + success: boolean output: string fileContents: {} errors: homescriptError[] } export interface homescriptError { + syntaxError: syntaxError + diagnosticError: diagnosticError + runtimeError: runtimeError + span: span +} + +export interface syntaxError { + message: string +} + +export interface diagnosticError { + kind: number + message: string + notes: string[] +} + +export interface runtimeError { kind: string message: string - span: span } export interface span { diff --git a/web/src/pages/dash/App.svelte b/web/src/pages/dash/App.svelte index 8861891d..2c7302c2 100644 --- a/web/src/pages/dash/App.svelte +++ b/web/src/pages/dash/App.svelte @@ -50,7 +50,14 @@ Widget Crashed
- {res.errors[0].kind}: {res.errors[0].message} + {#if res.errors[0].syntaxError !== null} + SyntaxError: {res.errors[0].syntaxError.message} + {:else if res.errors[0].diagnosticError !== null} + SemanticError: {res.errors[0].diagnosticError.message} + {:else} + {res.errors[0].runtimeError.kind}: {res.errors[0] + .runtimeError.message} + {/if} {/if} {/await} diff --git a/web/src/pages/hmsEditor/App.svelte b/web/src/pages/hmsEditor/App.svelte index ad3434be..3580a0d4 100644 --- a/web/src/pages/hmsEditor/App.svelte +++ b/web/src/pages/hmsEditor/App.svelte @@ -207,26 +207,32 @@ currentExecutionCount++ currentExecutionHandles++ try { - if (currentData.data.code === '') output = 'Nothing to lint.' - else { - const currentExecResTemp = await lintHomescriptCode( - currentData.data.code, - [], - currentData.data.id, - ) - let diagnostics = currentExecResTemp.errors - // If Info diagnostics should be hidden, do it here - if (!showLintInfo) diagnostics = diagnostics.filter(d => d.kind !== 'Info') - currentExecRes = { - code: currentData.data.code, - modeRun: false, - exitCode: currentExecResTemp.exitCode, - errors: diagnostics, - fileContents: currentExecResTemp.fileContents, - success: currentExecResTemp.success, - } - output = currentExecResTemp.output + const currentExecResTemp = await lintHomescriptCode( + currentData.data.code, + [], + currentData.data.id, + ) + let errs = currentExecResTemp.errors + + // If hint and info diagnostics should be hidden, do it here + if (!showLintInfo) + errs = errs.filter(d => { + if (d.diagnosticError !== null) { + if (d.diagnosticError.kind <= 1) { + return false + } + } + return true + }) + + currentExecRes = { + code: currentData.data.code, + modeRun: false, + errors: errs, + fileContents: currentExecResTemp.fileContents, + success: currentExecResTemp.success, } + output = currentExecResTemp.output } catch (err) { $createSnackbar(`Failed to lint '${currentScript}': ${err}`) } @@ -299,7 +305,6 @@ currentExecRes = { code: currentData.data.code, modeRun: true, - exitCode: message.exitCode, errors: message.errors, fileContents: message.fileContents, success: message.success, @@ -372,9 +377,7 @@ id="header__left__errors" class:error={!currentExecRes.modeRun && !currentExecRes.success} > - {currentExecRes.success ? 'done' : 'error'} + {currentExecRes.success ? 'done' : 'error'} {currentExecRes.success ? 'working' : 'errors'}
{/if} @@ -506,7 +509,8 @@ } } - &__save, &__errors { + &__save, + &__errors { color: var(--clr-text-disabled); display: flex; align-items: center; @@ -521,7 +525,8 @@ font-size: 1.25em; } - &.unsaved, &.error { + &.unsaved, + &.error { color: var(--clr-error); } } diff --git a/web/src/pages/hmsEditor/websocket.ts b/web/src/pages/hmsEditor/websocket.ts index ff1dde3c..74cc6c5d 100644 --- a/web/src/pages/hmsEditor/websocket.ts +++ b/web/src/pages/hmsEditor/websocket.ts @@ -14,7 +14,6 @@ export interface hmsResMessage { export interface hmsResWrapper { code: string modeRun: boolean - exitCode: number fileContents: {} errors: homescriptError[] success: boolean