async cmd

This commit is contained in:
5rahim
2025-03-20 13:36:50 +00:00
parent 4321093900
commit 84ede0e43a
8 changed files with 606 additions and 190 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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<string, string>;
headers?: Record<string, string>
}
/**
@@ -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<string, string> };
function parse(contentType: string): { mediaType: string; parameters: Record<string, string> }
}

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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
// }

View File

@@ -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)