diff --git a/DESCRIPTION b/DESCRIPTION index 6c8abbd..517d789 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,32 +1,33 @@ Package: livecode Title: Broadcast a source file to multiple concurrant users -Version: 0.1.0.9000 +Version: 0.1.0.9001 Authors@R: person(given = "Colin", family = "Rundel", role = c("aut", "cre"), email = "rundel@gmail.com") Description: Broadcast a local R (or other text) document over the web and provide live updates as it is edited. -License: GPL-3 +License: GPL (>= 3) Encoding: UTF-8 LazyData: true Imports: usethis, readr, glue, - withr, jsonlite, httpuv, httr, purrr, Rcpp, iptools, - markdown, later, tibble, crayon, rstudioapi, - stringi -RoxygenNote: 7.1.0 + stringi, + markdown, + processx, + withr +RoxygenNote: 7.3.3 LinkingTo: Rcpp diff --git a/LICENSE.md b/LICENSE.md index 3dcbf4e..175443c 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -553,7 +553,7 @@ and each file should have at least the “copyright” line and a pointer to where the full notice is found. - Copyright (C) 2020 Colin Rundel + Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -573,7 +573,7 @@ Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: - livecode Copyright (C) 2020 Colin Rundel + Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type 'show w'. This is free software, and you are welcome to redistribute it under certain conditions; type 'show c' for details. diff --git a/NAMESPACE b/NAMESPACE index 8dca8a9..d74646f 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,15 +1,20 @@ # Generated by roxygen2: do not edit by hand +export(FileCache) export(bitly_get_groups) export(bitly_get_token) export(bitly_reset_token) export(bitly_set_token) export(bitly_shorten) export(bitly_test_token) +export(file_cache) export(lc_server_iface) export(list_servers) export(network_interfaces) export(serve_file) export(stop_all) importFrom(Rcpp,sourceCpp) +importFrom(stats,setNames) +importFrom(utils,URLencode) +importFrom(utils,browseURL) useDynLib(livecode, .registration = TRUE) diff --git a/R/bitly_api.R b/R/bitly_api.R index 10153f9..64fda63 100644 --- a/R/bitly_api.R +++ b/R/bitly_api.R @@ -41,6 +41,7 @@ bitly_api_groups = function() { bitly_api("/groups")[["groups"]] } +#' @importFrom utils URLencode browseURL bitly_api_shorten = function(long_url, group_guid = bitly_get_groups()[1]) { bitly_api( "/shorten", "POST", @@ -50,23 +51,87 @@ bitly_api_shorten = function(long_url, group_guid = bitly_get_groups()[1]) { ) } +#' Retrieve available Bitly groups +#' +#' @description +#' Queries the authenticated Bitly account and returns the groups available +#' to the current user. Bitly groups are organizational containers used to +#' manage branded links, users, and link ownership. +#' +#' This function returns a named character vector where the names are group +#' names and the values are the corresponding Bitly group GUIDs. +#' +#' @return +#' A named character vector of Bitly group GUIDs. Names correspond to the +#' human-readable Bitly group names. +#' +#' @details +#' This function is primarily used internally by \code{bitly_shorten()} to +#' determine a default group when creating a short link. +#' +#' @examples +#' \dontrun{ +#' # View available Bitly groups +#' bitly_get_groups() +#' } +#' +#' @importFrom stats setNames +#' @seealso +#' \code{\link{bitly_shorten}} +#' #' @export bitly_get_groups = function() { l = bitly_api_groups() names = purrr::map_chr(l, "name") guids = purrr::map_chr(l, "guid") - + setNames(guids, names) } + + +#' Create a Bitly short link +#' +#' @description +#' Creates a shortened Bitly link for a supplied URL using the authenticated +#' Bitly account. +#' +#' By default, the short link is created using the first available Bitly group +#' returned by \code{bitly_get_groups()}. +#' +#' @param url Character string giving the URL to shorten. +#' @param guid Character string giving the Bitly group GUID under which the +#' short link should be created. Defaults to the first available group. +#' +#' @return +#' A character string containing the shortened Bitly URL. +#' +#' @details +#' A success message is displayed when the short link is created. +#' +#' This function requires a valid Bitly personal access token configured via +#' \code{bitly_get_token()}. +#' +#' @examples +#' \dontrun{ +#' # Shorten a local livecode URL +#' bitly_shorten("http://192.168.1.10:4321") +#' +#' # Shorten using a specific Bitly group +#' groups <- bitly_get_groups() +#' bitly_shorten("http://192.168.1.10:4321", guid = groups[1]) +#' } +#' +#' @seealso +#' \code{\link{bitly_get_groups}} +#' #' @export bitly_shorten = function(url, guid = bitly_get_groups()[1]) { - # TODO: add group handling link = bitly_api_shorten(url, guid)[["link"]] - + usethis::ui_done( "Created bitlink {usethis::ui_value(link)} for {usethis::ui_value(url)}." ) - + link } diff --git a/R/bitly_auth.R b/R/bitly_auth.R index 1c91ae6..2717ccb 100644 --- a/R/bitly_auth.R +++ b/R/bitly_auth.R @@ -1,50 +1,167 @@ +#' Retrieve the active Bitly token +#' +#' @description +#' Retrieves the Bitly personal access token used for authentication with the +#' Bitly API. +#' +#' The token is searched for in the following order: +#' \enumerate{ +#' \item The \code{BITLY_PAT} environment variable. +#' \item A local file at \code{~/.bitly/token}. +#' } +#' +#' If a token file is found, it is loaded automatically using +#' \code{bitly_set_token()}. +#' +#' @return +#' An invisible character string containing the active Bitly token. +#' +#' @details +#' If no token can be located, an error is raised instructing the user to set +#' a token manually. +#' +#' @examples +#' \dontrun{ +#' bitly_get_token() +#' } +#' +#' @seealso +#' \code{\link{bitly_set_token}}, +#' \code{\link{bitly_reset_token}}, +#' \code{\link{bitly_test_token}} +#' #' @export - bitly_get_token = function() { token = Sys.getenv("BITLY_PAT", "") if (token != "") return(invisible(token)) - + if (file.exists("~/.bitly/token")) { bitly_set_token("~/.bitly/token") return(invisible(bitly_get_token())) } - - usethis::ui_stop( paste0( + + usethis::ui_stop(paste0( "Unable to locate bitly token, please use {usethis::ui_code('bitly_set_token')}", " or define the BITLY_PAT environmental variable." - ) ) + )) } -#' @export + +#' Set the active Bitly token +#' +#' @description +#' Sets the Bitly personal access token used for authentication with the Bitly +#' API by storing it in the current R session environment variable +#' \code{BITLY_PAT}. +#' +#' The token may be supplied directly as a character string or indirectly as a +#' path to a file containing the token. +#' +#' @param token Character string giving the Bitly token, or a path to a file +#' containing the token. +#' +#' @return +#' Invisibly returns \code{NULL}. Called for side effects. +#' +#' @details +#' If \code{token} is a valid file path, the first line(s) of the file are read +#' and used as the token value. +#' +#' This affects only the current R session unless the user also stores the token +#' in a startup file such as \code{~/.Renviron}. +#' +#' @examples +#' \dontrun{ +#' # Set directly +#' bitly_set_token("your_token_here") +#' +#' # Set from file +#' bitly_set_token("~/.bitly/token") +#' } +#' +#' @seealso +#' \code{\link{bitly_get_token}}, +#' \code{\link{bitly_reset_token}} +#' +#' @export bitly_set_token = function(token) { token = as.character(token) - + if (file.exists(token)) - token = readLines(token, warn=FALSE) - + token = readLines(token, warn = FALSE) + Sys.setenv(BITLY_PAT = token) } -#' @export + +#' Remove the active Bitly token +#' +#' @description +#' Removes the \code{BITLY_PAT} environment variable from the current R session, +#' effectively clearing the active Bitly token. +#' +#' @return +#' Invisibly returns \code{NULL}. Called for side effects. +#' +#' @examples +#' \dontrun{ +#' bitly_reset_token() +#' } +#' +#' @seealso +#' \code{\link{bitly_set_token}}, +#' \code{\link{bitly_get_token}} +#' +#' @export bitly_reset_token = function() { Sys.unsetenv("BITLY_PAT") } + +#' Test Bitly authentication +#' +#' @description +#' Verifies that the current Bitly personal access token can successfully +#' authenticate with the Bitly API. +#' +#' By default, the token is obtained from \code{bitly_get_token()}. +#' +#' @param token Character string giving a Bitly token to test. Defaults to the +#' active token returned by \code{bitly_get_token()}. +#' +#' @return +#' Returns the result of \code{status_msg()}, typically a logical or invisible +#' status indicator depending on implementation. +#' +#' @details +#' A success or failure message is displayed indicating whether authentication +#' succeeded. +#' +#' @examples +#' \dontrun{ +#' bitly_test_token() +#' +#' bitly_test_token("your_token_here") +#' } +#' +#' @seealso +#' \code{\link{bitly_get_token}}, +#' \code{\link{bitly_set_token}} +#' #' @export bitly_test_token = function(token = bitly_get_token()) { res = purrr::safely(bitly_api_user)() - + status_msg( res, "Your bitly token is functioning correctly.", - "Your bitly token failed to authenticate.", + "Your bitly token failed to authenticate." ) } - bitly_available = function() { res = purrr::safely(bitly_api_user)() succeeded(res) diff --git a/R/file_cache.R b/R/file_cache.R index 50a91ea..67c4a6d 100644 --- a/R/file_cache.R +++ b/R/file_cache.R @@ -1,42 +1,91 @@ +#' File cache for tracking file changes +#' +#' @description +#' An R6 class that caches the contents of a file and updates the cache +#' only when the file changes on disk. +#' +#' @export FileCache = R6::R6Class( "FileCache", public = list( + + #' @description + #' Create a new FileCache object. + #' + #' @param path Path to a file. + #' @param file_id Optional file identifier. initialize = function(path, file_id = NULL) { path = normalizePath(path) + if (!file.exists(path)) usethis::ui_stop("Unable to locate file {usethis::ui_value(path)}") - + private$path = path private$file_id = file_id self$update_content() }, + + #' @description + #' Determine whether the file has changed since the last cache update. + #' + #' @return Logical scalar. need_update = function() { cur_mtime = file.mtime(private$path) cur_mtime > private$mtime }, + + #' @description + #' Refresh the cached file contents. + #' + #' @return + #' Returns the object invisibly. update_content = function() { - #message("Updating content") private$mtime = file.mtime(private$path) private$cache_content = readr::read_file(private$path) self } ), + private = list( path = NULL, file_id = NULL, mtime = NULL, cache_content = NULL ), + active = list( + + #' @field content + #' Cached file contents. Automatically refreshes if the file has changed. content = function() { if (self$need_update()) { self$update_content() } + private$cache_content } ) ) - +#' Create a file cache +#' +#' @description +#' Convenience wrapper for \code{FileCache$new()}. +#' +#' @param path Path to a file. +#' @param file_id Optional editor file identifier. +#' +#' @return +#' A \code{FileCache} R6 object. +#' +#' @examples +#' \dontrun{ +#' fc <- file_cache("script.R") +#' fc$content +#' } +#' @seealso +#' \code{\link{FileCache}} +#' +#' @export file_cache = function(path, file_id = NULL) { FileCache$new(path, file_id) } diff --git a/R/file_stream_server.R b/R/file_stream_server.R index abfa98f..7142455 100644 --- a/R/file_stream_server.R +++ b/R/file_stream_server.R @@ -37,7 +37,69 @@ lc_server <- R6::R6Class( ) -file_stream_server = function(host, port, file, file_id, interval = 3, template = "prism") { +#' Create a low-level livecode file streaming server +#' +#' @description +#' Creates and returns a low-level HTTP/WebSocket server used by +#' \pkg{livecode} to broadcast the contents of a source file to connected +#' browsers. +#' +#' The server hosts an HTML page, serves supporting static assets, and maintains +#' WebSocket connections to all connected clients. File changes, editor cursor +#' selections, and queued messages are pushed to clients at a fixed interval. +#' +#' This function is primarily an internal constructor used by +#' \code{\link{serve_file}} and \code{LiveCodeServer_Interface}. +#' +#' @param host Character string giving the IP address or hostname on which the +#' server should listen. +#' @param port Integer port number for the server. +#' @param file Path to the file being broadcast. +#' @param file_id Optional editor-specific file identifier used by RStudio for +#' auto-save operations. +#' @param interval Numeric update interval in seconds between broadcast ticks. +#' @param template Character string naming the HTML template to use. Defaults +#' to \code{"prism"}. +#' +#' @details +#' The server performs the following tasks: +#' +#' \itemize{ +#' \item Serves a rendered HTML page based on the selected template. +#' \item Tracks the source file using \code{\link{file_cache}}. +#' \item Pushes updated file contents to clients when the file changes. +#' \item Pushes queued messages from the server message queue. +#' \item In RStudio, optionally saves the file on each update tick. +#' \item In RStudio, broadcasts the current editor selection when the served +#' file is the active document. +#' \item Serves static assets (JavaScript, CSS, etc.) from the package +#' resources directory under \code{/web}. +#' } +#' +#' Connected clients receive JSON messages over WebSocket containing one or more +#' of: +#' +#' \itemize{ +#' \item \code{content}: updated file contents +#' \item \code{messages}: queued notifications +#' \item \code{selection}: highlighted line selection +#' \item \code{interval}: refresh interval +#' } +#' +#' @return +#' A \code{LiveCodeServer} R6 object inheriting from +#' \code{httpuv:::WebServer}. +#' +#' @seealso +#' \code{\link{serve_file}}, +#' \code{\link{file_cache}}, +#' \code{\link{lc_server_iface}} +#' +#' @keywords internal +file_stream_server = function(host, port, file, file_id, + interval = 3, + template = "prism", + track_selection = TRUE) { port = as.integer(port) file_cache = file_cache(file) page = glue::glue( @@ -45,98 +107,96 @@ file_stream_server = function(host, port, file, file_id, interval = 3, template lang = "r", title = file ) - + get_next_ws_id = local({ next_ws_id = 0L function() { sprintf("%012d", next_ws_id <<- next_ws_id + 1L) } }) - + websockets = new.env(parent = emptyenv()) - + websocket_loop = function() { if (!server$isRunning()) return() - + if (is_rstudio() & !is.null(file_id)) { rstudioapi::documentSave(file_id) } - + msg = list(interval = interval) if (file_cache$need_update()) msg[["content"]] = file_cache$content - + if (server$have_msgs()) { msgs = purrr::map(server$get_msgs(), ~ .$get_msg()) - msg[["messages"]] = msgs } - - if (is_rstudio()) { - ctx = rstudioapi::getSourceEditorContext() - open_file = path.expand(ctx[["path"]]) - - if (file == open_file) { - ln = extract_line_nums(ctx[["selection"]]) - msg[["selection"]] = ln + + if (track_selection && is_rstudio()) { + ctx <- tryCatch( + rstudioapi::getSourceEditorContext(), + error = function(e) NULL + ) + + if (!is.null(ctx) && !is.null(ctx[["path"]]) && nzchar(ctx[["path"]])) { + open_file <- path.expand(ctx[["path"]]) + + if (identical(file, open_file)) { + ln <- extract_line_nums(ctx[["selection"]]) + msg[["selection"]] <- ln + } } } - + msg = jsonlite::toJSON(msg, auto_unbox = TRUE) - - for(ws_id in names(websockets)) { + + for (ws_id in names(websockets)) { websockets[[ws_id]]$send(msg) } - + later::later(websocket_loop, interval) } - + app = list( call = function(req) { list( status = 200L, headers = list( - #'Content-Type' = 'text/html' - 'Content-Type'='text/html; charset=UTF-8' + 'Content-Type' = 'text/html; charset=UTF-8' ), body = page ) }, - + onWSOpen = function(ws) { ws_id = get_next_ws_id() websockets[[ws_id]] = ws - - ws$onClose( - function() { - rm(list = ws_id, envir = websockets) - } - ) - - ## Send initial message with current file contents + + ws$onClose(function() { + rm(list = ws_id, envir = websockets) + }) + msg = list( interval = interval, content = file_cache$content ) ws$send(jsonlite::toJSON(msg, auto_unbox = TRUE)) - + if (as.integer(ws_id) == 1) websocket_loop() }, - + staticPaths = list( "/web" = livecode:::pkg_resource("resources") ) ) - - # Must be defined for the websocket_loop above to work + server = lc_server$new(host, port, app) - server } - -#' livecode server interface +#' Livecode Server Interface #' #' @description #' This is a high level, user facing interface class that allows @@ -144,7 +204,6 @@ file_stream_server = function(host, port, file, file_id, interval = 3, template #' The interface also provides additional tools for sending messages. #' #' @export - lc_server_iface = R6::R6Class( "LiveCodeServer_Interface", cloneable = FALSE, @@ -157,107 +216,104 @@ lc_server_iface = R6::R6Class( interval = NULL, bitly_url = NULL, server = NULL, - + track_selection = NULL, + ngrok_process = NULL, + ngrok_domain = NULL, + ngrok_bin = NULL, + .public_url = NULL, + init_file = function(file, auto_save) { - + if (missing(file)) file = NULL else if (!is.null(file)) file = path.expand(file) - + file_id = NULL if (is_rstudio()) { if (is.character(file)) { rstudioapi::navigateToFile(file) Sys.sleep(0.5) } - + ctx = rstudioapi::getSourceEditorContext() - + file = path.expand(ctx[["path"]]) file_id = ctx[["id"]] } - + if (!auto_save) file_id = NULL - + if (is.null(file) | file == "") { - usethis::ui_stop( paste( + usethis::ui_stop(paste( "No file specified, if you are using RStudio ", "make sure the current open file has been saved ", "at least once." - ) ) + )) } - + private$file = file private$file_id = file_id }, - + init_ip = function(ip) { if (missing(ip)) { ip = network_interfaces()[["ip"]][1] - - #usethis::ui_info( c( - # "No ip address provided, using {usethis::ui_value(ip)}", - # "(If this does not work check available ips using {usethis::ui_code(\"network_interfaces()\")})" - #)) } - + if (is.na(iptools::ip_classify(ip))) { - usethis::ui_stop( paste( + usethis::ui_stop(paste( "Invalid ip address provided ({usethis::ui_value(ip)})." - ) ) + )) } - + private$ip = ip }, - + init_port = function(port) { if (missing(port)) { port = httpuv::randomPort(host = private$ip) - #usethis::ui_info( paste( - # "No port provided, using port {usethis::ui_value(port)}." - #)) } - + port = as.integer(port) - + if (port < 1024L | port > 49151L) { - usethis::ui_stop( paste( - "Invalid port ({usethis::ui_value(ip)}), value must be between 1024 and 49151." - ) ) + usethis::ui_stop(paste( + "Invalid port ({usethis::ui_value(port)}), value must be between 1024 and 49151." + )) } - + private$port = port }, - + init_bitly = function() { res = purrr::safely(bitly_shorten)(self$url) if (succeeded(res)) { private$bitly_url = result(res) } else { - usethis::ui_oops( paste0( + usethis::ui_oops(paste0( "Failed to create bitlink: ", error_msg(res) - ) ) + )) } }, - + init_auto_save = function() { if (!check_strip_trailing_ws()) return() - - opt_name = usethis::ui_value('Strip trailing horizontal whitespace when saving') + + opt_name = usethis::ui_value("Strip trailing horizontal whitespace when saving") if (using_project()) menu = "Tools > Project Options > Code Editing" else menu = "Tools > Global Options > Code > Saving" - - usethis::ui_oops( paste( + + usethis::ui_oops(paste( "You are running livecode with {usethis::ui_code('auto_save=TRUE')} with the {opt_name}", "option checked in RStudio. This can result in undesirable behavior while you broadcast.\n", "To resolve this, from RStudio's menu select:\n {menu} and uncheck {opt_name}." - ) ) + )) } ), public = list( @@ -272,27 +328,27 @@ lc_server_iface = R6::R6Class( #' @param auto_save should the broadcast file be auto saved update tic. #' @param open_browser should a browser session be opened. initialize = function( - file, ip, port, interval = 2, - bitly = FALSE, auto_save = TRUE, open_browser = TRUE + file, ip, port, interval = 2, + bitly = FALSE, auto_save = TRUE, open_browser = TRUE ) { private$init_file(file, auto_save) private$init_ip(ip) private$init_port(port) - + private$track_selection = !auto_save private$template = "prism" private$interval = interval self$start() - + if (bitly) private$init_bitly() - + if (auto_save) private$init_auto_save() - + if (open_browser) later::later(~self$open(), 1) }, - + #' @description #' Open server in browser open = function() { @@ -301,18 +357,18 @@ lc_server_iface = R6::R6Class( else usethis::ui_stop("The server is not currently running!") }, - + #' @description #' Class print method print = function() { - usethis::ui_line( paste( + usethis::ui_line(paste( crayon::bold("livecode server:"), crayon::red(fs::path_file(private$file)), "@", crayon::underline(crayon::blue(self$url)) - ) ) + )) }, - + #' @description #' Send a noty message to all connected users on the next update tic. #' @@ -331,61 +387,61 @@ lc_server_iface = R6::R6Class( if (parse_md) { text = markdown::markdownToHTML( text = text, - fragment.only = TRUE, - extensions = markdown::markdownExtensions() + fragment.only = TRUE ) } else { text = paste(text, collapse = "\n") } - + args = c( list(text = text, type = type, theme = theme, layout = layout), list(...) ) - + text_has_link = grepl("", - "", - "{server$url}", - "", - "" + "" ) ) - + + if (!is.null(server$public_url)) { + welcome_msg <- c( + welcome_msg, + "", + "Public URL:", + "", + glue::glue( + "" + ) + ) + } + server$send_msg(text = welcome_msg) - invisible(server) } - - - diff --git a/R/livecode.R b/R/livecode.R index e554c10..73aafab 100644 --- a/R/livecode.R +++ b/R/livecode.R @@ -1,16 +1,5 @@ -#' Server for broadcasting source code to multiple viewers -#' -#' Broadcast a local R (or other text) document over the web and provide live updates as it is edited. -#' -#' @seealso \link{serve_file} -#' -#' @name livecode-package -#' @aliases livecode -#' @docType package -#' @title Source code broadcasting server -#' @author Colin Rundel \email{rundel@gmail.com} -#' @keywords package -NULL +#' @keywords internal +"_PACKAGE" ## usethis namespace: start #' @importFrom Rcpp sourceCpp diff --git a/R/ngrok.R b/R/ngrok.R new file mode 100644 index 0000000..e2446f8 --- /dev/null +++ b/R/ngrok.R @@ -0,0 +1,25 @@ +query_ngrok_public_url = function(api = "http://127.0.0.1:4040/api/tunnels", + timeout = 5, interval = 0.2) { + deadline = Sys.time() + timeout + repeat { + res = purrr::safely(httr::GET)(api, httr::timeout(1)) + if (succeeded(res)) { + tunnels = jsonlite::fromJSON( + httr::content(result(res), as = "text", encoding = "UTF-8"), + simplifyVector = FALSE + )[["tunnels"]] + + https = purrr::keep(tunnels, ~ identical(.[["proto"]], "https")) + if (length(https) > 0) + return(https[[1]][["public_url"]]) + + if (length(tunnels) > 0) + return(tunnels[[1]][["public_url"]]) + } + + if (Sys.time() >= deadline) + return(NULL) + + Sys.sleep(interval) + } +} diff --git a/R/servers.R b/R/servers.R index e8c8962..8894b40 100644 --- a/R/servers.R +++ b/R/servers.R @@ -24,15 +24,79 @@ deregister_server = function(server) { invisible() } +#' Stop all active livecode servers +#' +#' @description +#' Stops all currently registered \code{livecode} server instances. +#' +#' This is a convenience function for shutting down every active server created +#' during the current R session, including any associated background tunnels +#' (for example, ngrok tunnels started through \code{serve_file()}). +#' +#' @details +#' The function iterates over all server objects stored in the internal +#' server registry and calls \code{$stop()} on each one. +#' +#' This is useful when multiple livecode sessions have been started and you want +#' to cleanly terminate all of them at once. +#' +#' @return +#' Invisibly returns \code{NULL}. Called for side effects. +#' +#' @examples +#' \dontrun{ +#' # Start multiple servers +#' serve_file("script1.R") +#' serve_file("script2.R") +#' +#' # Stop them all +#' stop_all() +#' } +#' +#' @seealso +#' \code{\link{serve_file}}, +#' \code{\link{list_servers}} +#' #' @export stop_all = function() { purrr::walk(.globals$servers, ~ .$stop()) } + + +#' List active livecode servers +#' +#' @description +#' Returns the currently registered \code{livecode} server objects active in the +#' current R session. +#' +#' Each element is a \code{LiveCodeServer_Interface} R6 object representing a +#' running (or previously started) server instance. +#' +#' @details +#' This function is useful for inspecting currently active servers, manually +#' interacting with specific server objects, or debugging multiple concurrent +#' sessions. +#' +#' @return +#' A list of \code{LiveCodeServer_Interface} objects. +#' +#' @examples +#' \dontrun{ +#' # Start a server +#' srv <- serve_file("script.R") +#' +#' # View active servers +#' list_servers() +#' } +#' +#' @seealso +#' \code{\link{serve_file}}, +#' \code{\link{stop_all}} +#' #' @export list_servers = function() { .globals$servers } - diff --git a/R/util_options.R b/R/util_options.R index bde6d2a..2ccb54a 100644 --- a/R/util_options.R +++ b/R/util_options.R @@ -59,12 +59,30 @@ get_option = function(x, default = NULL) { if (!is_rstudio()) { - default - } else { - .rs.readUiPref(x) %||% default + return(default) } + + rstudio_env <- tryCatch( + as.environment("tools:rstudio"), + error = function(e) NULL + ) + + if (is.null(rstudio_env)) { + return(default) + } + + read_ui_pref <- get0( + ".rs.readUiPref", + envir = rstudio_env, + mode = "function" + ) + + if (is.null(read_ui_pref)) { + return(default) + } + + read_ui_pref(x, default = default) } - check_strip_trailing_ws = function() { get_option("strip_trailing_whitespace", default = FALSE) } diff --git a/inst/resources/livecode/livecode.js b/inst/resources/livecode/livecode.js index 38b0f94..bd30447 100644 --- a/inst/resources/livecode/livecode.js +++ b/inst/resources/livecode/livecode.js @@ -1,4 +1,7 @@ -var ws = new WebSocket("ws://"+window.location.host); +var ws = new WebSocket( + (window.location.protocol === "https:" ? "wss://" : "ws://") + + window.location.host +); draw_pb = function(interval) { var pb = document.getElementById('progressbar'); diff --git a/man/FileCache.Rd b/man/FileCache.Rd new file mode 100644 index 0000000..9e47d73 --- /dev/null +++ b/man/FileCache.Rd @@ -0,0 +1,88 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/file_cache.R +\name{FileCache} +\alias{FileCache} +\title{File cache for tracking file changes} +\description{ +An R6 class that caches the contents of a file and updates the cache +only when the file changes on disk. +} +\section{Active bindings}{ +\if{html}{\out{
}} +\describe{ +\item{\code{content}}{Cached file contents. Automatically refreshes if the file has changed.} +} +\if{html}{\out{
}} +} +\section{Methods}{ +\subsection{Public methods}{ +\itemize{ +\item \href{#method-FileCache-new}{\code{FileCache$new()}} +\item \href{#method-FileCache-need_update}{\code{FileCache$need_update()}} +\item \href{#method-FileCache-update_content}{\code{FileCache$update_content()}} +\item \href{#method-FileCache-clone}{\code{FileCache$clone()}} +} +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-FileCache-new}{}}} +\subsection{Method \code{new()}}{ +Create a new FileCache object. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{FileCache$new(path, file_id = NULL)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{path}}{Path to a file.} + +\item{\code{file_id}}{Optional file identifier.} +} +\if{html}{\out{
}} +} +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-FileCache-need_update}{}}} +\subsection{Method \code{need_update()}}{ +Determine whether the file has changed since the last cache update. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{FileCache$need_update()}\if{html}{\out{
}} +} + +\subsection{Returns}{ +Logical scalar. +} +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-FileCache-update_content}{}}} +\subsection{Method \code{update_content()}}{ +Refresh the cached file contents. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{FileCache$update_content()}\if{html}{\out{
}} +} + +\subsection{Returns}{ +Returns the object invisibly. +} +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-FileCache-clone}{}}} +\subsection{Method \code{clone()}}{ +The objects of this class are cloneable with this method. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{FileCache$clone(deep = FALSE)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{deep}}{Whether to make a deep clone.} +} +\if{html}{\out{
}} +} +} +} diff --git a/man/bitly_get_groups.Rd b/man/bitly_get_groups.Rd new file mode 100644 index 0000000..491f7b0 --- /dev/null +++ b/man/bitly_get_groups.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/bitly_api.R +\name{bitly_get_groups} +\alias{bitly_get_groups} +\title{Retrieve available Bitly groups} +\usage{ +bitly_get_groups() +} +\value{ +A named character vector of Bitly group GUIDs. Names correspond to the +human-readable Bitly group names. +} +\description{ +Queries the authenticated Bitly account and returns the groups available +to the current user. Bitly groups are organizational containers used to +manage branded links, users, and link ownership. + +This function returns a named character vector where the names are group +names and the values are the corresponding Bitly group GUIDs. +} +\details{ +This function is primarily used internally by \code{bitly_shorten()} to +determine a default group when creating a short link. +} +\examples{ +\dontrun{ +# View available Bitly groups +bitly_get_groups() +} + +} +\seealso{ +\code{\link{bitly_shorten}} +} diff --git a/man/bitly_get_token.Rd b/man/bitly_get_token.Rd new file mode 100644 index 0000000..42e0262 --- /dev/null +++ b/man/bitly_get_token.Rd @@ -0,0 +1,39 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/bitly_auth.R +\name{bitly_get_token} +\alias{bitly_get_token} +\title{Retrieve the active Bitly token} +\usage{ +bitly_get_token() +} +\value{ +An invisible character string containing the active Bitly token. +} +\description{ +Retrieves the Bitly personal access token used for authentication with the +Bitly API. + +The token is searched for in the following order: +\enumerate{ + \item The \code{BITLY_PAT} environment variable. + \item A local file at \code{~/.bitly/token}. +} + +If a token file is found, it is loaded automatically using +\code{bitly_set_token()}. +} +\details{ +If no token can be located, an error is raised instructing the user to set +a token manually. +} +\examples{ +\dontrun{ +bitly_get_token() +} + +} +\seealso{ +\code{\link{bitly_set_token}}, +\code{\link{bitly_reset_token}}, +\code{\link{bitly_test_token}} +} diff --git a/man/bitly_reset_token.Rd b/man/bitly_reset_token.Rd new file mode 100644 index 0000000..11e2375 --- /dev/null +++ b/man/bitly_reset_token.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/bitly_auth.R +\name{bitly_reset_token} +\alias{bitly_reset_token} +\title{Remove the active Bitly token} +\usage{ +bitly_reset_token() +} +\value{ +Invisibly returns \code{NULL}. Called for side effects. +} +\description{ +Removes the \code{BITLY_PAT} environment variable from the current R session, +effectively clearing the active Bitly token. +} +\examples{ +\dontrun{ +bitly_reset_token() +} + +} +\seealso{ +\code{\link{bitly_set_token}}, +\code{\link{bitly_get_token}} +} diff --git a/man/bitly_set_token.Rd b/man/bitly_set_token.Rd new file mode 100644 index 0000000..d9cdc1e --- /dev/null +++ b/man/bitly_set_token.Rd @@ -0,0 +1,44 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/bitly_auth.R +\name{bitly_set_token} +\alias{bitly_set_token} +\title{Set the active Bitly token} +\usage{ +bitly_set_token(token) +} +\arguments{ +\item{token}{Character string giving the Bitly token, or a path to a file +containing the token.} +} +\value{ +Invisibly returns \code{NULL}. Called for side effects. +} +\description{ +Sets the Bitly personal access token used for authentication with the Bitly +API by storing it in the current R session environment variable +\code{BITLY_PAT}. + +The token may be supplied directly as a character string or indirectly as a +path to a file containing the token. +} +\details{ +If \code{token} is a valid file path, the first line(s) of the file are read +and used as the token value. + +This affects only the current R session unless the user also stores the token +in a startup file such as \code{~/.Renviron}. +} +\examples{ +\dontrun{ +# Set directly +bitly_set_token("your_token_here") + +# Set from file +bitly_set_token("~/.bitly/token") +} + +} +\seealso{ +\code{\link{bitly_get_token}}, +\code{\link{bitly_reset_token}} +} diff --git a/man/bitly_shorten.Rd b/man/bitly_shorten.Rd new file mode 100644 index 0000000..15b51dc --- /dev/null +++ b/man/bitly_shorten.Rd @@ -0,0 +1,44 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/bitly_api.R +\name{bitly_shorten} +\alias{bitly_shorten} +\title{Create a Bitly short link} +\usage{ +bitly_shorten(url, guid = bitly_get_groups()[1]) +} +\arguments{ +\item{url}{Character string giving the URL to shorten.} + +\item{guid}{Character string giving the Bitly group GUID under which the +short link should be created. Defaults to the first available group.} +} +\value{ +A character string containing the shortened Bitly URL. +} +\description{ +Creates a shortened Bitly link for a supplied URL using the authenticated +Bitly account. + +By default, the short link is created using the first available Bitly group +returned by \code{bitly_get_groups()}. +} +\details{ +A success message is displayed when the short link is created. + +This function requires a valid Bitly personal access token configured via +\code{bitly_get_token()}. +} +\examples{ +\dontrun{ +# Shorten a local livecode URL +bitly_shorten("http://192.168.1.10:4321") + +# Shorten using a specific Bitly group +groups <- bitly_get_groups() +bitly_shorten("http://192.168.1.10:4321", guid = groups[1]) +} + +} +\seealso{ +\code{\link{bitly_get_groups}} +} diff --git a/man/bitly_test_token.Rd b/man/bitly_test_token.Rd new file mode 100644 index 0000000..93edc0c --- /dev/null +++ b/man/bitly_test_token.Rd @@ -0,0 +1,38 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/bitly_auth.R +\name{bitly_test_token} +\alias{bitly_test_token} +\title{Test Bitly authentication} +\usage{ +bitly_test_token(token = bitly_get_token()) +} +\arguments{ +\item{token}{Character string giving a Bitly token to test. Defaults to the +active token returned by \code{bitly_get_token()}.} +} +\value{ +Returns the result of \code{status_msg()}, typically a logical or invisible +status indicator depending on implementation. +} +\description{ +Verifies that the current Bitly personal access token can successfully +authenticate with the Bitly API. + +By default, the token is obtained from \code{bitly_get_token()}. +} +\details{ +A success or failure message is displayed indicating whether authentication +succeeded. +} +\examples{ +\dontrun{ +bitly_test_token() + +bitly_test_token("your_token_here") +} + +} +\seealso{ +\code{\link{bitly_get_token}}, +\code{\link{bitly_set_token}} +} diff --git a/man/file_cache.Rd b/man/file_cache.Rd new file mode 100644 index 0000000..a9ed627 --- /dev/null +++ b/man/file_cache.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/file_cache.R +\name{file_cache} +\alias{file_cache} +\title{Create a file cache} +\usage{ +file_cache(path, file_id = NULL) +} +\arguments{ +\item{path}{Path to a file.} + +\item{file_id}{Optional editor file identifier.} +} +\value{ +A \code{FileCache} R6 object. +} +\description{ +Convenience wrapper for \code{FileCache$new()}. +} +\examples{ +\dontrun{ +fc <- file_cache("script.R") +fc$content +} +} +\seealso{ +\code{\link{FileCache}} +} diff --git a/man/file_stream_server.Rd b/man/file_stream_server.Rd index 5989f10..e5efb5f 100644 --- a/man/file_stream_server.Rd +++ b/man/file_stream_server.Rd @@ -2,10 +2,70 @@ % Please edit documentation in R/file_stream_server.R \name{file_stream_server} \alias{file_stream_server} -\title{Content-Type' = 'text/html'} +\title{Create a low-level livecode file streaming server} \usage{ file_stream_server(host, port, file, file_id, interval = 3, template = "prism") } +\arguments{ +\item{host}{Character string giving the IP address or hostname on which the +server should listen.} + +\item{port}{Integer port number for the server.} + +\item{file}{Path to the file being broadcast.} + +\item{file_id}{Optional editor-specific file identifier used by RStudio for +auto-save operations.} + +\item{interval}{Numeric update interval in seconds between broadcast ticks.} + +\item{template}{Character string naming the HTML template to use. Defaults +to \code{"prism"}.} +} +\value{ +A \code{LiveCodeServer} R6 object inheriting from +\code{httpuv:::WebServer}. +} \description{ -Content-Type' = 'text/html' +Creates and returns a low-level HTTP/WebSocket server used by +\pkg{livecode} to broadcast the contents of a source file to connected +browsers. + +The server hosts an HTML page, serves supporting static assets, and maintains +WebSocket connections to all connected clients. File changes, editor cursor +selections, and queued messages are pushed to clients at a fixed interval. + +This function is primarily an internal constructor used by +\code{\link{serve_file}} and \code{LiveCodeServer_Interface}. +} +\details{ +The server performs the following tasks: + +\itemize{ + \item Serves a rendered HTML page based on the selected template. + \item Tracks the source file using \code{\link{file_cache}}. + \item Pushes updated file contents to clients when the file changes. + \item Pushes queued messages from the server message queue. + \item In RStudio, optionally saves the file on each update tick. + \item In RStudio, broadcasts the current editor selection when the served + file is the active document. + \item Serves static assets (JavaScript, CSS, etc.) from the package + resources directory under \code{/web}. +} + +Connected clients receive JSON messages over WebSocket containing one or more +of: + +\itemize{ + \item \code{content}: updated file contents + \item \code{messages}: queued notifications + \item \code{selection}: highlighted line selection + \item \code{interval}: refresh interval +} +} +\seealso{ +\code{\link{serve_file}}, +\code{\link{file_cache}}, +\code{\link{lc_server_iface}} } +\keyword{internal} diff --git a/man/lc_server_iface.Rd b/man/lc_server_iface.Rd index 6703c93..74cd221 100644 --- a/man/lc_server_iface.Rd +++ b/man/lc_server_iface.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/file_stream_server.R \name{lc_server_iface} \alias{lc_server_iface} -\title{livecode server interface} +\title{Livecode Server Interface} \description{ This is a high level, user facing interface class that allows for the creation of a livecode server sharing a specific file. @@ -14,25 +14,31 @@ The interface also provides additional tools for sending messages. \item{\code{url}}{The current url of the server.} \item{\code{path}}{The path of the file being served.} + +\item{\code{public_url}}{The current public ngrok URL, if any.} + +\item{\code{tunnel_active}}{Whether an ngrok tunnel is currently active.} } \if{html}{\out{}} } \section{Methods}{ \subsection{Public methods}{ \itemize{ -\item \href{#method-new}{\code{lc_server_iface$new()}} -\item \href{#method-open}{\code{lc_server_iface$open()}} -\item \href{#method-print}{\code{lc_server_iface$print()}} -\item \href{#method-send_msg}{\code{lc_server_iface$send_msg()}} -\item \href{#method-is_running}{\code{lc_server_iface$is_running()}} -\item \href{#method-start}{\code{lc_server_iface$start()}} -\item \href{#method-stop}{\code{lc_server_iface$stop()}} -\item \href{#method-restart}{\code{lc_server_iface$restart()}} +\item \href{#method-LiveCodeServer_Interface-new}{\code{lc_server_iface$new()}} +\item \href{#method-LiveCodeServer_Interface-open}{\code{lc_server_iface$open()}} +\item \href{#method-LiveCodeServer_Interface-print}{\code{lc_server_iface$print()}} +\item \href{#method-LiveCodeServer_Interface-send_msg}{\code{lc_server_iface$send_msg()}} +\item \href{#method-LiveCodeServer_Interface-is_running}{\code{lc_server_iface$is_running()}} +\item \href{#method-LiveCodeServer_Interface-start}{\code{lc_server_iface$start()}} +\item \href{#method-LiveCodeServer_Interface-stop}{\code{lc_server_iface$stop()}} +\item \href{#method-LiveCodeServer_Interface-start_ngrok}{\code{lc_server_iface$start_ngrok()}} +\item \href{#method-LiveCodeServer_Interface-stop_ngrok}{\code{lc_server_iface$stop_ngrok()}} +\item \href{#method-LiveCodeServer_Interface-restart}{\code{lc_server_iface$restart()}} } } \if{html}{\out{
}} -\if{html}{\out{}} -\if{latex}{\out{\hypertarget{method-new}{}}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-LiveCodeServer_Interface-new}{}}} \subsection{Method \code{new()}}{ Creates a new livecode server \subsection{Usage}{ @@ -68,8 +74,8 @@ Creates a new livecode server } } \if{html}{\out{
}} -\if{html}{\out{}} -\if{latex}{\out{\hypertarget{method-open}{}}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-LiveCodeServer_Interface-open}{}}} \subsection{Method \code{open()}}{ Open server in browser \subsection{Usage}{ @@ -78,8 +84,8 @@ Open server in browser } \if{html}{\out{
}} -\if{html}{\out{}} -\if{latex}{\out{\hypertarget{method-print}{}}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-LiveCodeServer_Interface-print}{}}} \subsection{Method \code{print()}}{ Class print method \subsection{Usage}{ @@ -88,8 +94,8 @@ Class print method } \if{html}{\out{
}} -\if{html}{\out{}} -\if{latex}{\out{\hypertarget{method-send_msg}{}}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-LiveCodeServer_Interface-send_msg}{}}} \subsection{Method \code{send_msg()}}{ Send a noty message to all connected users on the next update tic. \subsection{Usage}{ @@ -122,8 +128,8 @@ Send a noty message to all connected users on the next update tic. } } \if{html}{\out{
}} -\if{html}{\out{}} -\if{latex}{\out{\hypertarget{method-is_running}{}}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-LiveCodeServer_Interface-is_running}{}}} \subsection{Method \code{is_running()}}{ Determine if the server is running. \subsection{Usage}{ @@ -135,8 +141,8 @@ Returns `TRUE` if the server is running. } } \if{html}{\out{
}} -\if{html}{\out{}} -\if{latex}{\out{\hypertarget{method-start}{}}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-LiveCodeServer_Interface-start}{}}} \subsection{Method \code{start()}}{ Start the server \subsection{Usage}{ @@ -145,8 +151,8 @@ Start the server } \if{html}{\out{
}} -\if{html}{\out{}} -\if{latex}{\out{\hypertarget{method-stop}{}}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-LiveCodeServer_Interface-stop}{}}} \subsection{Method \code{stop()}}{ Stop the server \subsection{Usage}{ @@ -162,8 +168,37 @@ Stop the server } } \if{html}{\out{
}} -\if{html}{\out{}} -\if{latex}{\out{\hypertarget{method-restart}{}}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-LiveCodeServer_Interface-start_ngrok}{}}} +\subsection{Method \code{start_ngrok()}}{ +Start an ngrok tunnel for the server. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{lc_server_iface$start_ngrok(domain = NULL, ngrok_bin = "ngrok")}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{domain}}{Optional reserved ngrok domain.} + +\item{\code{ngrok_bin}}{Path to ngrok binary.} +} +\if{html}{\out{
}} +} +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-LiveCodeServer_Interface-stop_ngrok}{}}} +\subsection{Method \code{stop_ngrok()}}{ +Stop the ngrok tunnel if one is active. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{lc_server_iface$stop_ngrok()}\if{html}{\out{
}} +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-LiveCodeServer_Interface-restart}{}}} \subsection{Method \code{restart()}}{ Restart the server \subsection{Usage}{ diff --git a/man/list_servers.Rd b/man/list_servers.Rd new file mode 100644 index 0000000..061e42f --- /dev/null +++ b/man/list_servers.Rd @@ -0,0 +1,37 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/servers.R +\name{list_servers} +\alias{list_servers} +\title{List active livecode servers} +\usage{ +list_servers() +} +\value{ +A list of \code{LiveCodeServer_Interface} objects. +} +\description{ +Returns the currently registered \code{livecode} server objects active in the +current R session. + +Each element is a \code{LiveCodeServer_Interface} R6 object representing a +running (or previously started) server instance. +} +\details{ +This function is useful for inspecting currently active servers, manually +interacting with specific server objects, or debugging multiple concurrent +sessions. +} +\examples{ +\dontrun{ +# Start a server +srv <- serve_file("script.R") + +# View active servers +list_servers() +} + +} +\seealso{ +\code{\link{serve_file}}, +\code{\link{stop_all}} +} diff --git a/man/livecode-package.Rd b/man/livecode-package.Rd index 4d566d6..d367f68 100644 --- a/man/livecode-package.Rd +++ b/man/livecode-package.Rd @@ -2,19 +2,16 @@ % Please edit documentation in R/livecode.R \docType{package} \name{livecode-package} -\alias{livecode-package} \alias{livecode} -\title{Source code broadcasting server} +\alias{livecode-package} +\title{livecode: Broadcast a source file to multiple concurrant users} \description{ -Server for broadcasting source code to multiple viewers -} -\details{ +\if{html}{\figure{logo.png}{options: style='float: right' alt='logo' width='120'}} + Broadcast a local R (or other text) document over the web and provide live updates as it is edited. } -\seealso{ -\link{serve_file} -} \author{ -Colin Rundel \email{rundel@gmail.com} +\strong{Maintainer}: Colin Rundel \email{rundel@gmail.com} + } -\keyword{package} +\keyword{internal} diff --git a/man/serve_file.Rd b/man/serve_file.Rd index e35edf1..20a5ec2 100644 --- a/man/serve_file.Rd +++ b/man/serve_file.Rd @@ -11,24 +11,98 @@ serve_file( interval = 1, bitly = FALSE, auto_save = TRUE, - open_browser = TRUE + open_browser = TRUE, + tunnel = FALSE, + ngrok_domain = NULL, + ngrok_bin = "ngrok", + ... ) } \arguments{ -\item{file}{Path to file to broadcast.} +\item{file}{Path to the file to broadcast. If not provided and running in +RStudio, the currently active document is used.} -\item{ip}{ip of the server, defaults to the top result of `network_interfaces`.} +\item{ip}{IP address for the local server. Defaults to the first available +network interface (see \code{network_interfaces()}).} -\item{port}{port of the server, defaults to a random value.} +\item{port}{Port for the local server. Defaults to a random available port.} -\item{interval}{page update interval in seconds.} +\item{interval}{Numeric. Update interval in seconds for refreshing content +and pushing updates to clients.} -\item{bitly}{should a bitly bit link be created for the server.} +\item{bitly}{Logical. Should a Bitly short link be generated for the local +server URL.} -\item{auto_save}{should the broadcast file be auto saved during each update tic.} +\item{auto_save}{Logical. Should the source file be automatically saved at +each update interval (RStudio only).} -\item{open_browser}{should a browser session be opened.} +\item{open_browser}{Logical. Should a browser session be opened automatically. +If \code{tunnel = TRUE} and a public URL is available, the browser will open +the public URL; otherwise, it opens the local URL.} + +\item{tunnel}{Logical. Should an \code{ngrok} tunnel be started to expose the +local server. Requires \code{ngrok} to be installed and available on the +system path.} + +\item{ngrok_domain}{Optional character string specifying a custom ngrok domain. +Requires an ngrok account with reserved domains.} + +\item{ngrok_bin}{Character string giving the path to the \code{ngrok} binary. +Defaults to \code{"ngrok"} (assumes it is available on the system path).} + +\item{...}{Additional arguments (currently ignored).} +} +\value{ +An invisible \code{LiveCodeServer_Interface} R6 object representing the running +server. This object provides methods such as: +\itemize{ + \item \code{$stop()} to stop the server (and any active tunnel) + \item \code{$restart()} to restart the server + \item \code{$send_msg()} to send messages to connected clients + \item \code{$start_ngrok()} and \code{$stop_ngrok()} to manage the tunnel +} } \description{ -Create a livecode server for broadcasting a file +Starts a local \code{livecode} server that streams a syntax-highlighted +version of a source file and automatically updates it in connected browsers. +The server uses a WebSocket connection to push updates at a fixed interval. + +Optionally, an \href{https://ngrok.com}{ngrok} tunnel can be launched to expose +the local server to the public internet via a secure HTTPS URL. +} +\details{ +The server is implemented using \pkg{httpuv} and maintains an open WebSocket +connection to all clients. Updates are pushed at the specified \code{interval}, +including: +\itemize{ + \item Updated file contents + \item Cursor/selection position (RStudio only) + \item Messages sent via \code{send_msg()} +} + +When \code{tunnel = TRUE}, an ngrok process is launched in the background using +\pkg{processx}. The process is attached to the returned server object and can +be stopped using \code{$stop_ngrok()} or \code{$stop()}. + +Note that when accessing the server via HTTPS (e.g., through ngrok), WebSocket +connections must use the \code{wss://} protocol. This is handled automatically +in the client JavaScript. +} +\examples{ +\dontrun{ +srv <- serve_file("script.R") + +srv <- serve_file("script.R", tunnel = TRUE) + +srv <- serve_file( + "script.R", + tunnel = TRUE, + ngrok_domain = "mydomain.ngrok.io" +) + +srv$send_msg("Hello, everyone!", type = "success") + +srv$stop() +} + } diff --git a/man/stop_all.Rd b/man/stop_all.Rd new file mode 100644 index 0000000..d2432e2 --- /dev/null +++ b/man/stop_all.Rd @@ -0,0 +1,40 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/servers.R +\name{stop_all} +\alias{stop_all} +\title{Stop all active livecode servers} +\usage{ +stop_all() +} +\value{ +Invisibly returns \code{NULL}. Called for side effects. +} +\description{ +Stops all currently registered \code{livecode} server instances. + +This is a convenience function for shutting down every active server created +during the current R session, including any associated background tunnels +(for example, ngrok tunnels started through \code{serve_file()}). +} +\details{ +The function iterates over all server objects stored in the internal +server registry and calls \code{$stop()} on each one. + +This is useful when multiple livecode sessions have been started and you want +to cleanly terminate all of them at once. +} +\examples{ +\dontrun{ +# Start multiple servers +serve_file("script1.R") +serve_file("script2.R") + +# Stop them all +stop_all() +} + +} +\seealso{ +\code{\link{serve_file}}, +\code{\link{list_servers}} +} diff --git a/src/RcppExports.cpp b/src/RcppExports.cpp index a944df3..14b2d44 100644 --- a/src/RcppExports.cpp +++ b/src/RcppExports.cpp @@ -5,6 +5,11 @@ using namespace Rcpp; +#ifdef RCPP_USE_GLOBAL_ROSTREAM +Rcpp::Rostream& Rcpp::Rcout = Rcpp::Rcpp_cout_get(); +Rcpp::Rostream& Rcpp::Rcerr = Rcpp::Rcpp_cerr_get(); +#endif + // get_ipv4 Rcpp::CharacterVector get_ipv4(); RcppExport SEXP _livecode_get_ipv4() {