diff --git a/internal/extension_repo/goja.go b/internal/extension_repo/goja.go index 8128ba1b0..f6a47f81d 100644 --- a/internal/extension_repo/goja.go +++ b/internal/extension_repo/goja.go @@ -9,6 +9,7 @@ import ( goja_bindings "seanime/internal/goja/goja_bindings" "seanime/internal/library/anime" "seanime/internal/plugin" + "sync" "time" "github.com/5rahim/habari" @@ -38,6 +39,7 @@ func ShareBinds(vm *goja.Runtime, logger *zerolog.Logger) { fm := goja_bindings.DefaultFieldMapper{} vm.SetFieldNameMapper(fm) + goja.TagFieldNameMapper("json", true) bindings := []struct { name string @@ -171,6 +173,25 @@ func ShareBinds(vm *goja.Runtime, logger *zerolog.Logger) { return anime.NewLocalFileWrapper(lfs) }) vm.Set("$animeUtils", animeUtilsObj) + + vm.Set("$waitGroup", func() *sync.WaitGroup { + return &sync.WaitGroup{} + }) + + // Run a function in a new goroutine + // The Goja runtime is not thread safe, so nothing related to the VM should be done in this goroutine + // You can use the $waitGroup to wait for multiple goroutines to finish + // You can use $store to communicate with the main thread + vm.Set("$unsafeGoroutine", func(fn func()) { + go func() { + defer func() { + if r := recover(); r != nil { + logger.Error().Err(fmt.Errorf("%v", r)).Msg("goroutine panic") + } + }() + fn() + }() + }) } // JSVMTypescriptToJS converts typescript to javascript diff --git a/internal/extension_repo/goja_plugin_system_test.go b/internal/extension_repo/goja_plugin_system_test.go index 998c5a1cb..94abd30ec 100644 --- a/internal/extension_repo/goja_plugin_system_test.go +++ b/internal/extension_repo/goja_plugin_system_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "seanime/internal/extension" + "seanime/internal/plugin" "strings" "testing" "time" @@ -989,7 +990,7 @@ function init() { manager.PrintPluginPoolMetrics(opts.ID) } -// TestGojaPluginSystemDownloader tests the $downloader bindings in the Goja plugin system +// TestGojaPluginSystemDownloader tests the ctx.downloader bindings in the Goja plugin system func TestGojaPluginSystemDownloader(t *testing.T) { // Create a temporary directory for testing tempDir := t.TempDir() @@ -1036,12 +1037,12 @@ func TestGojaPluginSystemDownloader(t *testing.T) { payload := ` function init() { $ui.register((ctx) => { - console.log("Testing $downloader bindings with large file"); + console.log("Testing ctx.downloader bindings with large file"); // Test download const downloadPath = "${TEMP_DIR}/large_download.bin"; try { - const downloadID = $downloader.download("${SERVER_URL}", downloadPath, { + const downloadID = ctx.downloader.download("${SERVER_URL}", downloadPath, { timeout: 60 // 60 second timeout }); console.log("Download started with ID:", downloadID); @@ -1052,7 +1053,7 @@ function init() { // Wait for download to complete let downloadComplete = ctx.state(false); - const cancelWatch = $downloader.watch(downloadID, (progress) => { + const cancelWatch = ctx.downloader.watch(downloadID, (progress) => { // Store progress update progressUpdates.push({ percentage: progress.percentage, @@ -1095,12 +1096,12 @@ function init() { } // List downloads - const downloads = $downloader.listDownloads(); + const downloads = ctx.downloader.listDownloads(); console.log("Active downloads:", downloads.length); $store.set("downloadsCount", downloads.length); // Get progress - const progress = $downloader.getProgress(downloadID); + const progress = ctx.downloader.getProgress(downloadID); if (progress) { console.log("Final download progress:", progress); $store.set("finalProgress", progress); @@ -1137,27 +1138,27 @@ function init() { }, } - plugin, logger, manager, _, _, err := InitTestPlugin(t, opts) + p, logger, manager, _, _, err := InitTestPlugin(t, opts) require.NoError(t, err) // Wait for the plugin to execute and download to complete time.Sleep(12 * time.Second) // Check the store values - downloadID, ok := plugin.store.GetOk("downloadID") + downloadID, ok := p.store.GetOk("downloadID") require.True(t, ok, "downloadID should be set in store") assert.NotEmpty(t, downloadID) // Check if download completed or if there was an error - downloadComplete, ok := plugin.store.GetOk("downloadComplete") + downloadComplete, ok := p.store.GetOk("downloadComplete") if ok && downloadComplete.(bool) { // If download completed, check file size - downloadedSize, ok := plugin.store.GetOk("downloadedSize") + downloadedSize, ok := p.store.GetOk("downloadedSize") require.True(t, ok, "downloadedSize should be set in store") assert.Equal(t, int64(totalSize), downloadedSize) // Check progress updates - progressUpdates, ok := plugin.store.GetOk("progressUpdates") + progressUpdates, ok := p.store.GetOk("progressUpdates") require.True(t, ok, "progressUpdates should be set in store") updates, ok := progressUpdates.([]interface{}) require.True(t, ok, "progressUpdates should be a slice") @@ -1175,16 +1176,16 @@ function init() { } } - finalProgress, ok := plugin.store.GetOk("finalProgress") + finalProgress, ok := p.store.GetOk("finalProgress") if ok { - progressMap, ok := finalProgress.(map[string]interface{}) - require.True(t, ok, "finalProgress should be a map") - assert.Equal(t, "completed", progressMap["status"]) - assert.InDelta(t, 100.0, progressMap["percentage"], 0.1) + progressMap, ok := finalProgress.(*plugin.DownloadProgress) + require.Truef(t, ok, "finalProgress should be a map, got %T", finalProgress) + assert.Equal(t, "completed", progressMap.Status) + assert.InDelta(t, 100.0, progressMap.Percentage, 0.1) } } else { // If download failed, check error - downloadError, _ := plugin.store.GetOk("downloadError") + downloadError, _ := p.store.GetOk("downloadError") t.Logf("Download error: %v", downloadError) // Don't fail the test if there was an error, just log it } @@ -1444,6 +1445,66 @@ function init() { console.log("Command execution error:", e.message); $store.set("commandError", e.message); } + + // Test executing an async command + //try { + // // Create a command to list files + // const asyncCmd = $osExtra.asyncCmd("ls", "-la", "${TEMP_DIR}"); + // + // + // asyncCmd.run((data, err, exitCode, signal) => { + // // console.log(data, err, exitCode, signal) + // if (data) { + // console.log("Async command data:", $toString(data)); + // } + // if (err) { + // console.log("Async command error:", $toString(err)); + // } + // if (exitCode) { + // console.log("Async command exit code:", exitCode); + // } + // if (signal) { + // console.log("Async command signal:", signal); + // } + // }); + //} catch (e) { + // console.log("Command execution error:", e.message); + // $store.set("asyncCommandError", e.message); + //} + + // Try unsafe goroutine + try { + // Create a command to list files + const cmd = $os.cmd("ls", "-la", "${TEMP_DIR}"); + + $store.watch("unsafeGoroutineOutput", (output) => { + console.log("Unsafe goroutine output:", output); + }); + + // Read the output using scanner + $unsafeGoroutine(function() { + // Set up stdout capture + const stdoutPipe = cmd.stdoutPipe(); + + console.log("Starting unsafe goroutine"); + const output = $io.readAll(stdoutPipe); + $store.set("unsafeGoroutineOutput", $toString(output)); + console.log("Unsafe goroutine output set", $toString(output)); + + cmd.wait(); + }); + + // Start the command + cmd.start(); + + // Check exit code + const exitCode = cmd.processState.exitCode(); + console.log("Command exit code:", exitCode); + + } catch (e) { + console.log("Command execution error:", e.message); + $store.set("unsafeGoroutineError", e.message); + } // Test executing a command with combined output try { @@ -1598,6 +1659,122 @@ function init() { manager.PrintPluginPoolMetrics(opts.ID) } +func TestGojaPluginSystemAsyncCommand(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create a test file to use with commands + testFilePath := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(testFilePath, []byte("Hello, world!"), 0644) + require.NoError(t, err) + + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing async command execution"); + + // Test executing an async command + try { + // Create a command to list files + let asyncCmd = $osExtra.asyncCmd("ls", "-la", "${TEMP_DIR}"); + + let output = ""; + asyncCmd.run((data, err, exitCode, signal) => { + // console.log(data, err, exitCode, signal) + if (data) { + // console.log("Async command data:", $toString(data)); + output += $toString(data) + "\n"; + $store.set("asyncCommandData", $toString(output)); + } + if (err) { + console.log("Async command error:", $toString(err)); + $store.set("asyncCommandError", $toString(err)); + } + if (exitCode !== undefined) { + console.log("output 1", output) + console.log("Async command exit code:", exitCode); + $store.set("asyncCommandExitCode", exitCode); + console.log("Async command signal:", signal); + $store.set("asyncCommandSignal", signal); + } + }); + + console.log("Running second command") + + let asyncCmd2 = $osExtra.asyncCmd("ls", "-la", "${TEMP_DIR}"); + + let output2 = ""; + asyncCmd2.run((data, err, exitCode, signal) => { + // console.log(data, err, exitCode, signal) + if (data) { + // console.log("Async command data:", $toString(data)); + output2 += $toString(data) + "\n"; + $store.set("asyncCommandData", $toString(output2)); + } + if (err) { + console.log("Async command error:", $toString(err)); + $store.set("asyncCommandError", $toString(err)); + } + if (exitCode !== undefined) { + console.log("output 2", output2) + console.log("Async command exit code:", exitCode); + $store.set("asyncCommandExitCode", exitCode); + console.log("Async command signal:", signal); + $store.set("asyncCommandSignal", signal); + } + }); + + } catch (e) { + console.log("Command execution error:", e.message); + $store.set("asyncCommandError", e.message); + } + }); +} + ` + + // Replace placeholders with actual paths + payload = strings.ReplaceAll(payload, "${TEMP_DIR}", tempDir) + payload = strings.ReplaceAll(payload, "${TEST_FILE_PATH}", testFilePath) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/**/*"}, + WritePaths: []string{tempDir + "/**/*"}, + CommandScopes: []extension.CommandScope{ + { + Command: "ls", + Args: []extension.CommandArg{ + {Value: "-la"}, + {Validator: "$PATH"}, + }, + }, + }, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(2 * time.Second) + + // Check the store values for the ls command + asyncCommandData, ok := plugin.store.GetOk("asyncCommandData") + require.True(t, ok, "asyncCommandData should be set in store") + assert.Contains(t, asyncCommandData.(string), "test.txt", "Command output should contain the test file") + + asyncCommandExitCode, ok := plugin.store.GetOk("asyncCommandExitCode") + require.True(t, ok, "asyncCommandExitCode should be set in store") + assert.Equal(t, int64(0), asyncCommandExitCode, "Command exit code should be 0") + + manager.PrintPluginPoolMetrics(opts.ID) +} + // TestGojaPluginSystemUnzip tests the unzip functionality in the osExtra module func TestGojaPluginSystemUnzip(t *testing.T) { // Create a temporary directory for testing diff --git a/internal/extension_repo/goja_plugin_types/plugin.d.ts b/internal/extension_repo/goja_plugin_types/plugin.d.ts index 13e48b793..b29eebd13 100644 --- a/internal/extension_repo/goja_plugin_types/plugin.d.ts +++ b/internal/extension_repo/goja_plugin_types/plugin.d.ts @@ -283,8 +283,6 @@ declare namespace $ui { */ playNextEpisode(): void - - } interface PlaybackEvent { @@ -1239,4 +1237,4 @@ declare namespace $habari { * @returns The metadata */ function parse(filename: string): Metadata -} +} \ No newline at end of file diff --git a/internal/extension_repo/goja_plugin_types/system.d.ts b/internal/extension_repo/goja_plugin_types/system.d.ts index 253f2375e..23e985db0 100644 --- a/internal/extension_repo/goja_plugin_types/system.d.ts +++ b/internal/extension_repo/goja_plugin_types/system.d.ts @@ -16,7 +16,7 @@ declare namespace $os { * @param args The arguments to pass to the command * @returns A command object or an error if the command is not authorized */ - function cmd(name: string, ...args: string[]): $os.Cmd; + function cmd(name: string, ...args: string[]): $os.Cmd /** * Reads the entire file specified by path. @@ -24,7 +24,7 @@ declare namespace $os { * @returns The file contents as a byte array * @throws Error if the path is not authorized for reading */ - function readFile(path: string): Uint8Array; + function readFile(path: string): Uint8Array /** * Writes data to the named file, creating it if necessary. @@ -34,7 +34,7 @@ declare namespace $os { * @param perm The file mode (permissions) * @throws Error if the path is not authorized for writing */ - function writeFile(path: string, data: Uint8Array, perm: number): void; + function writeFile(path: string, data: Uint8Array, perm: number): void /** * Reads a directory, returning a list of directory entries. @@ -42,35 +42,35 @@ declare namespace $os { * @returns An array of directory entries * @throws Error if the path is not authorized for reading */ - function readDir(path: string): $os.DirEntry[]; + function readDir(path: string): $os.DirEntry[] /** * Returns the default directory to use for temporary files. * @returns The temporary directory path or empty string if not authorized * @throws Error if the path is not authorized for reading */ - function tempDir(): string; + function tempDir(): string /** * Returns the user's configuration directory. * @returns The configuration directory path or empty string if not authorized * @throws Error if the path is not authorized for reading */ - function configDir(): string; + function configDir(): string /** * Returns the user's home directory. * @returns The home directory path or empty string if not authorized * @throws Error if the path is not authorized for reading */ - function homeDir(): string; + function homeDir(): string /** * Returns the user's cache directory. * @returns The cache directory path or empty string if not authorized * @throws Error if the path is not authorized for reading */ - function cacheDir(): string; + function cacheDir(): string /** * Changes the size of the named file. @@ -78,7 +78,7 @@ declare namespace $os { * @param size The new size of the file * @throws Error if the path is not authorized for writing */ - function truncate(path: string, size: number): void; + function truncate(path: string, size: number): void /** * Creates a new directory with the specified name and permission bits. @@ -86,7 +86,7 @@ declare namespace $os { * @param perm The permission bits * @throws Error if the path is not authorized for writing */ - function mkdir(path: string, perm: number): void; + function mkdir(path: string, perm: number): void /** * Creates a directory named path, along with any necessary parents. @@ -94,7 +94,7 @@ declare namespace $os { * @param perm The permission bits * @throws Error if the path is not authorized for writing */ - function mkdirAll(path: string, perm: number): void; + function mkdirAll(path: string, perm: number): void /** * Renames (moves) oldpath to newpath. @@ -102,21 +102,21 @@ declare namespace $os { * @param newpath The destination path * @throws Error if either path is not authorized for writing */ - function rename(oldpath: string, newpath: string): void; + function rename(oldpath: string, newpath: string): void /** * Removes the named file or (empty) directory. * @param path The path to remove * @throws Error if the path is not authorized for writing */ - function remove(path: string): void; + function remove(path: string): void /** * Removes path and any children it contains. * @param path The path to remove recursively * @throws Error if the path is not authorized for writing */ - function removeAll(path: string): void; + function removeAll(path: string): void /** * Returns a FileInfo describing the named file. @@ -124,7 +124,7 @@ declare namespace $os { * @returns Information about the file * @throws Error if the path is not authorized for reading */ - function stat(path: string): $os.FileInfo; + function stat(path: string): $os.FileInfo /** * Opens a file for reading and writing. @@ -133,36 +133,54 @@ declare namespace $os { * @param perm The file mode (permissions) * @returns A file object or an error if the file is not authorized for writing */ - function openFile(path: string, flag: number, perm: number): $os.File; + function openFile(path: string, flag: number, perm: number): $os.File interface File { - chmod(mode: $os.FileMode): void; - chown(uid: number, gid: number): void; - close(): void; - fd(): number; - name(): string; - read(b: Uint8Array): number; - readAt(b: Uint8Array, off: number): number; + chmod(mode: $os.FileMode): void - readDir(n: number): $os.DirEntry[]; - readFrom(r: io.Reader): number; + chown(uid: number, gid: number): void - readdir(n: number): $os.FileInfo[]; - readdirnames(n: number): string[]; - seek(offset: number, whence: number): number; - setDeadline(t: Date): void; - setReadDeadline(t: Date): void; - setWriteDeadline(t: Date): void; + close(): void - stat(): $os.FileInfo; - sync(): void; - syscallConn(): any; /* Not documented */ - truncate(size: number): void; - write(b: Uint8Array): number; - writeAt(b: Uint8Array, off: number): number; - writeString(s: string): number; - writeTo(w: io.Writer): number; + fd(): number + + name(): string + + read(b: Uint8Array): number + + readAt(b: Uint8Array, off: number): number + + readDir(n: number): $os.DirEntry[] + + readFrom(r: io.Reader): number + + readdir(n: number): $os.FileInfo[] + + readdirnames(n: number): string[] + + seek(offset: number, whence: number): number + + setDeadline(t: Date): void + + setReadDeadline(t: Date): void + + setWriteDeadline(t: Date): void + + stat(): $os.FileInfo + + sync(): void + + syscallConn(): any /* Not documented */ + truncate(size: number): void + + write(b: Uint8Array): number + + writeAt(b: Uint8Array, off: number): number + + writeString(s: string): number + + writeTo(w: io.Writer): number } /** @@ -175,128 +193,179 @@ declare namespace $os { * If the Args field is empty or nil, Run uses {Path}. * In typical use, both Path and Args are set by calling Command. */ - args: string[]; + args: string[] /** * If Cancel is non-nil, the command must have been created with CommandContext * and Cancel will be called when the command's Context is done. */ - cancel: (() => void); + cancel: () => void /** * Dir specifies the working directory of the command. * If Dir is the empty string, Run runs the command in the calling process's current directory. */ - dir: string; + dir: string /** * Env specifies the environment of the process. * Each entry is of the form "key=value". * If Env is nil, the new process uses the current process's environment. */ - env: string[]; + env: string[] /** Error information if the command failed */ - err: Error; + err: Error /** * ExtraFiles specifies additional open files to be inherited by the new process. * It does not include standard input, standard output, or standard error. */ - extraFiles: any[]; + extraFiles: $os.File[] /** * Path is the path of the command to run. * This is the only field that must be set to a non-zero value. */ - path: string; + path: string /** Process is the underlying process, once started. */ - process?: any; + process?: $os.Process /** ProcessState contains information about an exited process. */ - processState?: any; + processState?: $os.ProcessState /** Standard error of the command */ - stderr: any; + stderr: any /** Standard input of the command */ - stdin: any; + stdin: any /** Standard output of the command */ - stdout: any; + stdout: any /** SysProcAttr holds optional, operating system-specific attributes. */ - sysProcAttr?: any; + sysProcAttr?: any /** * If WaitDelay is non-zero, it bounds the time spent waiting on two sources of * unexpected delay in Wait: a child process that fails to exit after the associated * Context is canceled, and a child process that exits but leaves its I/O pipes unclosed. */ - waitDelay: number; + waitDelay: number /** * CombinedOutput runs the command and returns its combined standard output and standard error. * @returns The combined output as a string or byte array */ - combinedOutput(): string | number[]; + combinedOutput(): string | number[] /** * Environ returns a copy of the environment in which the command would be run as it is currently configured. * @returns The environment variables as an array of strings */ - environ(): string[]; + environ(): string[] /** * Output runs the command and returns its standard output. * @returns The standard output as a string or byte array */ - output(): string | number[]; + output(): string | number[] /** * Run starts the specified command and waits for it to complete. * The returned error is nil if the command runs, has no problems copying stdin, stdout, * and stderr, and exits with a zero exit status. */ - run(): void; + run(): void /** * Start starts the specified command but does not wait for it to complete. * If Start returns successfully, the c.Process field will be set. */ - start(): void; + start(): void /** * StderrPipe returns a pipe that will be connected to the command's standard error when the command starts. * @returns A readable stream for the command's standard error */ - stderrPipe(): any; + stderrPipe(): any /** * StdinPipe returns a pipe that will be connected to the command's standard input when the command starts. * @returns A writable stream for the command's standard input */ - stdinPipe(): any; + stdinPipe(): any /** * StdoutPipe returns a pipe that will be connected to the command's standard output when the command starts. * @returns A readable stream for the command's standard output */ - stdoutPipe(): any; + stdoutPipe(): any /** * String returns a human-readable description of the command. * It is intended only for debugging. * @returns A string representation of the command */ - string(): string; + string(): string /** * Wait waits for the command to exit and waits for any copying to stdin or copying from stdout or stderr to complete. * The command must have been started by Start. */ - wait(): void; + wait(): void + } + + interface Process { + kill(): void + + wait(): void + + signal(sig: Signal): void + } + + interface Signal { + string(): string + + signal(): void + } + + const Kill: Signal + const Interrupt: Signal + + interface ProcessState { + pid(): number + + string(): string + + exitCode(): number + } + + /** + * AsyncCmd represents an external command being prepared or run asynchronously. + */ + interface AsyncCmd { + /** + * Get the underlying $os.Cmd. + * To start the command, call start() on the underlying command, not run(). + * @returns The underlying $os.Cmd + */ + getCommand(): $os.Cmd + + /** + * Run the command + * @param callback The callback to call each time data is available from the command's stdout or stderr + * @param data The data from the command's stdout + * @param err The data from the command's stderr + * @param exitCode The exit code of the command + * @param signal The signal that terminated the command + */ + run(callback: (data: Uint8Array | undefined, + err: Uint8Array | undefined, + exitCode: number | undefined, + signal: string | undefined, + ) => void): void } /** @@ -304,22 +373,22 @@ declare namespace $os { */ interface FileInfo { /** Base name of the file */ - name(): string; + name(): string - /** Length in bytes for regular files; system-dependent for others */ - size(): number; + /** Length in bytes for regular files system-dependent for others */ + size(): number /** File mode bits */ - mode(): $os.FileMode; + mode(): $os.FileMode /** Modification time */ - modTime(): Date; + modTime(): Date /** Abbreviation for mode().isDir() */ - isDir(): boolean; + isDir(): boolean /** Underlying data source (can return null) */ - sys(): any; + sys(): any } /** @@ -327,22 +396,22 @@ declare namespace $os { */ interface DirEntry { /** Returns the name of the file (or subdirectory) described by the entry */ - name(): string; + name(): string /** Reports whether the entry describes a directory */ - isDir(): boolean; + isDir(): boolean /** Returns the type bits for the entry */ - type(): $os.FileMode; + type(): $os.FileMode /** Returns the FileInfo for the file or subdirectory described by the entry */ - info(): $os.FileInfo; + info(): $os.FileInfo } /** * FileMode represents a file's mode and permission bits. */ - type FileMode = number; + type FileMode = number /** * Constants for file mode bits @@ -407,35 +476,35 @@ declare namespace $filepath { * @param path The path to get the base name from * @returns The base name of the path */ - function base(path: string): string; + function base(path: string): string /** * Cleans the path by applying a series of rules. * @param path The path to clean * @returns The cleaned path */ - function clean(path: string): string; + function clean(path: string): string /** * Returns all but the last element of path. * @param path The path to get the directory from * @returns The directory containing the file */ - function dir(path: string): string; + function dir(path: string): string /** * Returns the file extension of path. * @param path The path to get the extension from * @returns The file extension (including the dot) */ - function ext(path: string): string; + function ext(path: string): string /** * Converts path from slash-separated to OS-specific separator. * @param path The path to convert * @returns The path with OS-specific separators */ - function fromSlash(path: string): string; + function fromSlash(path: string): string /** * Returns a list of files matching the pattern in the base directory. @@ -444,21 +513,21 @@ declare namespace $filepath { * @returns An array of matching file paths * @throws Error if the base path is not authorized for reading */ - function glob(basePath: string, pattern: string): string[]; + function glob(basePath: string, pattern: string): string[] /** * Reports whether the path is absolute. * @param path The path to check * @returns True if the path is absolute */ - function isAbs(path: string): boolean; + function isAbs(path: string): boolean /** * Joins any number of path elements into a single path. * @param paths The path elements to join * @returns The joined path */ - function join(...paths: string[]): string; + function join(...paths: string[]): string /** * Reports whether name matches the shell pattern. @@ -466,7 +535,7 @@ declare namespace $filepath { * @param name The string to check * @returns True if name matches pattern */ - function match(pattern: string, name: string): boolean; + function match(pattern: string, name: string): boolean /** * Returns the relative path from basepath to targpath. @@ -474,28 +543,28 @@ declare namespace $filepath { * @param targpath The target path * @returns The relative path */ - function rel(basepath: string, targpath: string): string; + function rel(basepath: string, targpath: string): string /** * Splits path into directory and file components. * @param path The path to split * @returns An array with [directory, file] */ - function split(path: string): [string, string]; + function split(path: string): [string, string] /** * Splits a list of paths joined by the OS-specific ListSeparator. * @param path The path list to split * @returns An array of paths */ - function splitList(path: string): string[]; + function splitList(path: string): string[] /** * Converts path from OS-specific separator to slash-separated. * @param path The path to convert * @returns The path with forward slashes */ - function toSlash(path: string): string; + function toSlash(path: string): string /** * Walks the file tree rooted at the specificed path, calling walkFn for each file or directory. @@ -504,7 +573,7 @@ declare namespace $filepath { * @param walkFn The function to call for each file or directory * @throws Error if the root path is not authorized for reading */ - function walk(root: string, walkFn: (path: string, info: $os.FileInfo, err: any | null | undefined) => any): void; + function walk(root: string, walkFn: (path: string, info: $os.FileInfo, err: any | null | undefined) => any): void /** * Walks the file tree rooted at the specificed path, calling walkDirFn for each file or directory. @@ -512,7 +581,7 @@ declare namespace $filepath { * @param walkDirFn The function to call for each file or directory * @throws Error if the root path is not authorized for reading */ - function walkDir(root: string, walkDirFn: (path: string, d: $os.DirEntry, err: any | null | undefined) => any): void; + function walkDir(root: string, walkDirFn: (path: string, d: $os.DirEntry, err: any | null | undefined) => any): void } /** @@ -525,7 +594,7 @@ declare namespace $osExtra { * @param dest The destination directory * @throws Error if either path is not authorized for writing */ - function unwrapAndMove(src: string, dest: string): void; + function unwrapAndMove(src: string, dest: string): void /** * Extracts a ZIP archive to the destination directory. @@ -533,7 +602,7 @@ declare namespace $osExtra { * @param dest The destination directory * @throws Error if either path is not authorized for writing */ - function unzip(src: string, dest: string): void; + function unzip(src: string, dest: string): void /** * Extracts a RAR archive to the destination directory. @@ -541,28 +610,37 @@ declare namespace $osExtra { * @param dest The destination directory * @throws Error if either path is not authorized for writing */ - function unrar(src: string, dest: string): void; + function unrar(src: string, dest: string): void /** * Returns the user's desktop directory. * @returns The desktop directory path or empty string if not authorized * @throws Error if the path is not authorized for reading */ - function desktopDir(): string; + function desktopDir(): string /** * Returns the user's documents directory. * @returns The documents directory path or empty string if not authorized * @throws Error if the path is not authorized for reading */ - function documentsDir(): string; + function documentsDir(): string /** * Returns the user's downloads directory. * @returns The downloads directory path or empty string if not authorized * @throws Error if the path is not authorized for reading */ - function downloadDir(): string; + function downloadDir(): string + + /** + * Creates a new AsyncCmd instance. + * Get the underlying $os.Cmd with getCommand(). + * @param name The name of the command to execute + * @param arg The arguments to pass to the command + * @returns A new AsyncCmd instance + */ + function asyncCmd(name: string, arg: string[]): $os.AsyncCmd } /** @@ -579,27 +657,27 @@ declare namespace $downloader { */ interface DownloadProgress { /** Unique download identifier */ - id: string; + id: string /** Source URL */ - url: string; + url: string /** Destination file path */ - destination: string; + destination: string /** Number of bytes downloaded so far */ - totalBytes: number; + totalBytes: number /** Total file size in bytes (if known) */ - totalSize: number; + totalSize: number /** Download speed in bytes per second */ - speed: number; + speed: number /** Download completion percentage (0-100) */ - percentage: number; + percentage: number /** Current download status */ - status: DownloadStatus; + status: DownloadStatus /** Error message if status is ERROR */ - error?: string; + error?: string /** Time of the last progress update */ - lastUpdate: Date; + lastUpdate: Date /** Time when the download started */ - startTime: Date; + startTime: Date } /** @@ -607,9 +685,9 @@ declare namespace $downloader { */ interface DownloadOptions { /** Timeout in seconds */ - timeout?: number; + timeout?: number /** HTTP headers to send with the request */ - headers?: Record; + headers?: Record } /** @@ -620,7 +698,7 @@ declare namespace $downloader { * @returns A unique download ID * @throws Error if the destination path is not authorized for writing */ - function download(url: string, destination: string, options?: DownloadOptions): string; + function download(url: string, destination: string, options?: DownloadOptions): string /** * Watches a download for progress updates. @@ -628,33 +706,33 @@ declare namespace $downloader { * @param callback Function to call with progress updates * @returns A function to cancel the watch */ - function watch(downloadId: string, callback: (progress: DownloadProgress | undefined) => void): () => void; + function watch(downloadId: string, callback: (progress: DownloadProgress | undefined) => void): () => void /** * Gets the current progress of a download. * @param downloadId The download ID to check * @returns The current download progress */ - function getProgress(downloadId: string): DownloadProgress | undefined; + function getProgress(downloadId: string): DownloadProgress | undefined /** * Lists all active downloads. * @returns An array of download progress objects */ - function listDownloads(): DownloadProgress[]; + function listDownloads(): DownloadProgress[] /** * Cancels a specific download. * @param downloadId The download ID to cancel * @returns True if the download was cancelled */ - function cancel(downloadId: string): boolean; + function cancel(downloadId: string): boolean /** * Cancels all active downloads. * @returns The number of downloads cancelled */ - function cancelAll(): number; + function cancelAll(): number } /** @@ -667,5 +745,5 @@ declare namespace $mime { * @returns An object containing the media type and parameters * @throws Error if parsing fails */ - function parse(contentType: string): { mediaType: string; parameters: Record }; + function parse(contentType: string): { mediaType: string; parameters: Record } } diff --git a/internal/plugin/app_context.go b/internal/plugin/app_context.go index 92fafb8d5..81914657d 100644 --- a/internal/plugin/app_context.go +++ b/internal/plugin/app_context.go @@ -7,7 +7,7 @@ import ( "seanime/internal/library/playbackmanager" "seanime/internal/mediaplayers/mediaplayer" "seanime/internal/platforms/platform" - "seanime/internal/util/goja" + goja_util "seanime/internal/util/goja" "github.com/dop251/goja" "github.com/rs/zerolog" @@ -55,6 +55,9 @@ type AppContext interface { // BindCronToContextObj binds 'cron' to the UI context object BindCronToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) *Cron + + // BindDownloaderToContextObj binds 'downloader' to the UI context object + BindDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) } var GlobalAppContext = NewAppContext() diff --git a/internal/plugin/downloader.go b/internal/plugin/downloader.go index d0775e8b0..5a946f257 100644 --- a/internal/plugin/downloader.go +++ b/internal/plugin/downloader.go @@ -27,7 +27,7 @@ const ( DownloadStatusError DownloadStatus = "error" ) -type downloadProgress struct { +type DownloadProgress struct { ID string `json:"id"` URL string `json:"url"` Destination string `json:"destination"` @@ -40,11 +40,11 @@ type downloadProgress struct { LastUpdateTime time.Time `json:"lastUpdate"` StartTime time.Time `json:"startTime"` - lastBytes int64 + lastBytes int64 `json:"-"` } // IsFinished returns true if the download has completed, errored, or been cancelled -func (p *downloadProgress) IsFinished() bool { +func (p *DownloadProgress) IsFinished() bool { return p.Status == string(DownloadStatusCompleted) || p.Status == string(DownloadStatusCancelled) || p.Status == string(DownloadStatusError) } @@ -55,37 +55,7 @@ type progressSubscriber struct { LastSent time.Time } -// BindDownload binds the download module to the Goja runtime. -// -// Example -// -// // Start multiple downloads -// const id1 = $downloader.download("https://example.com/file1.zip", "/downloads/file1.zip", { -// timeout: 300 // 5 minutes -// }); -// const id2 = $downloader.download("https://example.com/file2.zip", "/downloads/file2.zip", { -// timeout: 300 -// }); -// -// // List all active downloads -// const downloads = $downloader.listDownloads(); -// console.log(`Active downloads: ${downloads.length}`); -// -// // Track specific download -// const progress = $downloader.getProgress(id1); -// console.log(`Download ${progress.id}: ${progress.percentage}%, Speed: ${progress.speed} bytes/s`); -// -// // Watch download progress -// const cancelWatch = $downloader.watch(id1, (progress) => { -// console.log(`Download ${progress.id}: ${progress.percentage}%`); -// }); -// -// // Cancel specific download -// $downloader.cancel(id1); -// -// // Cancel all downloads -// $downloader.cancelAll(); -func (a *AppContextImpl) bindDownloader(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { +func (a *AppContextImpl) BindDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { downloadObj := vm.NewObject() progressMap := sync.Map{} @@ -126,7 +96,7 @@ func (a *AppContextImpl) bindDownloader(vm *goja.Runtime, logger *zerolog.Logger case <-ctx.Done(): // If download is complete/cancelled/errored, send one last update and stop if progress, ok := progressMap.Load(downloadID); ok { - p := progress.(*downloadProgress) + p := progress.(*DownloadProgress) scheduler.ScheduleAsync(func() error { p.Speed = 0 callback(goja.Undefined(), vm.ToValue(p)) @@ -136,7 +106,7 @@ func (a *AppContextImpl) bindDownloader(vm *goja.Runtime, logger *zerolog.Logger return case <-ticker.C: if progress, ok := progressMap.Load(downloadID); ok { - p := progress.(*downloadProgress) + p := progress.(*DownloadProgress) scheduler.ScheduleAsync(func() error { callback(goja.Undefined(), vm.ToValue(p)) return nil @@ -183,7 +153,7 @@ func (a *AppContextImpl) bindDownloader(vm *goja.Runtime, logger *zerolog.Logger // Initialize progress tracking now := time.Now() - progress := &downloadProgress{ + progress := &DownloadProgress{ ID: downloadID, URL: url, Destination: destination, @@ -316,17 +286,17 @@ func (a *AppContextImpl) bindDownloader(vm *goja.Runtime, logger *zerolog.Logger return downloadID, nil }) - _ = downloadObj.Set("getProgress", func(downloadID string) *downloadProgress { + _ = downloadObj.Set("getProgress", func(downloadID string) *DownloadProgress { if progress, ok := progressMap.Load(downloadID); ok { - return progress.(*downloadProgress) + return progress.(*DownloadProgress) } return nil }) - _ = downloadObj.Set("listDownloads", func() []*downloadProgress { - downloads := make([]*downloadProgress, 0) + _ = downloadObj.Set("listDownloads", func() []*DownloadProgress { + downloads := make([]*DownloadProgress, 0) progressMap.Range(func(key, value interface{}) bool { - downloads = append(downloads, value.(*downloadProgress)) + downloads = append(downloads, value.(*DownloadProgress)) return true }) return downloads @@ -354,5 +324,5 @@ func (a *AppContextImpl) bindDownloader(vm *goja.Runtime, logger *zerolog.Logger }) }) - _ = vm.Set("$downloader", downloadObj) + _ = obj.Set("downloader", downloadObj) } diff --git a/internal/plugin/system.go b/internal/plugin/system.go index dee037250..e30cee4ad 100644 --- a/internal/plugin/system.go +++ b/internal/plugin/system.go @@ -3,6 +3,7 @@ package plugin import ( "bufio" "bytes" + "context" "errors" "fmt" "io" @@ -17,6 +18,7 @@ import ( util "seanime/internal/util" goja_util "seanime/internal/util/goja" "strings" + "sync" "github.com/bmatcuk/doublestar/v4" "github.com/dop251/goja" @@ -32,6 +34,14 @@ const ( AllowPathWrite = 1 ) +type AsyncCmd struct { + cmd *exec.Cmd + + appContext *AppContextImpl + scheduler *goja_util.Scheduler + vm *goja.Runtime +} + // BindSystem binds the system module to the Goja runtime. // Permissions needed: system + allowlist func (a *AppContextImpl) BindSystem(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { @@ -60,8 +70,12 @@ func (a *AppContextImpl) BindSystem(vm *goja.Runtime, logger *zerolog.Logger, ex return nil, fmt.Errorf("command (%s) not authorized", fmt.Sprintf("%s %s", name, strings.Join(arg, " "))) } - return exec.Command(name, arg...), nil + return util.NewCmdCtx(context.Background(), name, arg...), nil }) + + _ = osObj.Set("Interrupt", os.Interrupt) + _ = osObj.Set("Kill", os.Kill) + _ = osObj.Set("readFile", func(path string) ([]byte, error) { if !a.isAllowedPath(ext, path, AllowPathRead) { return nil, fmt.Errorf("$os.readFile: path (%s) not authorized for read", path) @@ -208,7 +222,7 @@ func (a *AppContextImpl) BindSystem(vm *goja.Runtime, logger *zerolog.Logger, ex fileModeObj.Set("ModeIrregular", os.ModeIrregular) fileModeObj.Set("ModeType", os.ModeType) fileModeObj.Set("ModePerm", os.ModePerm) - _ = vm.Set("$os.FileMode", fileModeObj) + _ = osObj.Set("FileMode", fileModeObj) _ = vm.Set("$os", osObj) @@ -324,12 +338,6 @@ func (a *AppContextImpl) BindSystem(vm *goja.Runtime, logger *zerolog.Logger, ex _ = vm.Set("$bytes", bytesObj) - ////////////////////////////////////// - // Downloader - ////////////////////////////////////// - - a.bindDownloader(vm, logger, ext, scheduler) - ////////////////////////////////////// // Filepath ////////////////////////////////////// @@ -444,6 +452,15 @@ func (a *AppContextImpl) BindSystem(vm *goja.Runtime, logger *zerolog.Logger, ex return libraryDirs, nil }) + _ = osExtraObj.Set("asyncCmd", func(name string, arg ...string) *AsyncCmd { + return &AsyncCmd{ + cmd: util.NewCmdCtx(context.Background(), name, arg...), + appContext: a, + scheduler: scheduler, + vm: vm, + } + }) + _ = vm.Set("$osExtra", osExtraObj) ////////////////////////////////////// @@ -503,7 +520,7 @@ func (a *AppContextImpl) resolveEnvironmentPaths(name string) []string { return []string{} } return []string{configDir} - case "DOWNLOADS": + case "DOWNLOAD": downloadDir, err := util.DownloadDir() if err != nil { return []string{} @@ -515,7 +532,7 @@ func (a *AppContextImpl) resolveEnvironmentPaths(name string) []string { return []string{} } return []string{desktopDir} - case "DOCUMENTS": + case "DOCUMENT": documentsDir, err := util.DocumentsDir() if err != nil { return []string{} @@ -824,3 +841,153 @@ func (a *AppContextImpl) validateCommandArgs(ext *extension.Extension, allowedAr return true } + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// GetCommand returns the underlying exec.Cmd +func (c *AsyncCmd) GetCommand() *exec.Cmd { + return c.cmd +} + +func (c *AsyncCmd) Run(callback goja.Callable) error { + + stdout, err := c.cmd.StdoutPipe() + if err != nil { + return err + } + + stderr, err := c.cmd.StderrPipe() + if err != nil { + return err + } + + // Start the command + err = c.cmd.Start() + if err != nil { + return err + } + + // Use WaitGroup to ensure all goroutines complete before Wait() is called + var wg sync.WaitGroup + wg.Add(2) // One for stdout, one for stderr + + // Handle stdout + go func() { + defer wg.Done() + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + data := scanner.Bytes() + dataBytes := make([]byte, len(data)) + copy(dataBytes, data) + + c.scheduler.ScheduleAsync(func() error { + _, err := callback(goja.Undefined(), c.vm.ToValue(dataBytes), goja.Undefined(), goja.Undefined(), goja.Undefined()) + return err + }) + } + }() + + // Handle stderr + go func() { + defer wg.Done() + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + data := scanner.Bytes() + dataBytes := make([]byte, len(data)) + copy(dataBytes, data) + + c.scheduler.ScheduleAsync(func() error { + _, err := callback(goja.Undefined(), goja.Undefined(), c.vm.ToValue(dataBytes), goja.Undefined(), goja.Undefined()) + return err + }) + } + }() + + // Wait for both stdout and stderr to be fully processed in a separate goroutine + go func() { + // Wait for stdout and stderr goroutines to complete + wg.Wait() + + // Now wait for the command to finish + err := c.cmd.Wait() + + _ = stdout.Close() + _ = stderr.Close() + + // Process exit code and signal + exitCode := 0 + signal := "" + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + signal = exitErr.String() + } else if c.cmd.ProcessState != nil { + exitCode = c.cmd.ProcessState.ExitCode() + signal = c.cmd.ProcessState.String() + } + + // Call callback with exit code and signal + c.scheduler.ScheduleAsync(func() error { + _, err = callback(goja.Undefined(), goja.Undefined(), goja.Undefined(), c.vm.ToValue(exitCode), c.vm.ToValue(signal)) + return err + }) + }() + + return nil +} + +// // OnData registers a callback to be called when data is available from the command's stdout +// func (c *AsyncCmd) OnData(callback func(data []byte)) error { +// stdout, err := c.cmd.StdoutPipe() +// if err != nil { +// return err +// } + +// go func() { +// scanner := bufio.NewScanner(stdout) +// for scanner.Scan() { +// c.scheduler.ScheduleAsync(func() error { +// callback(scanner.Bytes()) +// return nil +// }) +// } +// }() + +// return nil +// } + +// // OnError registers a callback to be called when data is available from the command's stderr +// func (c *AsyncCmd) OnError(callback func(data []byte)) error { +// stderr, err := c.cmd.StderrPipe() +// if err != nil { +// return err +// } + +// go func() { +// scanner := bufio.NewScanner(stderr) +// for scanner.Scan() { +// c.scheduler.ScheduleAsync(func() error { +// callback(scanner.Bytes()) +// return nil +// }) +// } +// }() + +// return nil +// } + +// // OnExit registers a callback to be called when the command exits +// func (c *AsyncCmd) OnExit(callback func(code int, signal string)) error { +// go func() { +// err := c.cmd.Wait() +// if err != nil { +// return +// } +// c.scheduler.ScheduleAsync(func() error { +// callback(c.cmd.ProcessState.ExitCode(), c.cmd.ProcessState.String()) +// return nil +// }) +// }() +// return nil +// } diff --git a/internal/plugin/ui/context.go b/internal/plugin/ui/context.go index 6526dfa0d..8fdb196a8 100644 --- a/internal/plugin/ui/context.go +++ b/internal/plugin/ui/context.go @@ -147,6 +147,8 @@ func (c *Context) createAndBindContextObject(vm *goja.Runtime) { case extension.PluginPermissionPlayback: // Bind playback to the context object plugin.GlobalAppContext.BindPlaybackToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + case extension.PluginPermissionSystem: + plugin.GlobalAppContext.BindDownloaderToContextObj(vm, obj, c.logger, c.ext, c.scheduler) case extension.PluginPermissionCron: // Bind cron to the context object cron := plugin.GlobalAppContext.BindCronToContextObj(vm, obj, c.logger, c.ext, c.scheduler)