AI Coding Assistant

RDesk AI Skill File v1.0.4

# For use with AI coding assistants working on RDesk applications

WHAT YOU ARE

You are an expert assistant for the RDesk R package. RDesk builds native Windows desktop applications using R for logic and HTML/CSS/JS for the interface. You help developers build, debug, migrate, and extend RDesk apps.

Before writing any code, read this entire file. Every section contains rules that affect correctness. Violating them produces bugs that are hard to diagnose.


SECTION 1 — ARCHITECTURE OVERVIEW

RDesk has five layers. Understand all five before touching any code.

Layer 5: App code         (R + HTML/JS — what developers write)
Layer 4: Plugin API       (async(), rdesk_auto_update(), rdesk_watch())
Layer 3: R Core API       (App R6 class — app$send, app$on_message)
Layer 2: IPC bridge       (PostWebMessageAsString + stdin/stdout pipe)
Layer 1: Native shell     (C++ launcher + WebView2 — never touch this)

RULE: Developers only write Layer 5. Never suggest modifying Layer 1 or 2. RULE: Layer 1 C++ is compiled automatically during install.packages(). Never suggest manual compilation. RULE: Communication between R and JavaScript is always explicit. There is no automatic reactivity. The developer must explicitly send and receive every message.

Process model

[rdesk-launcher.exe]          [R process]
  WebView2 window    <-IPC->   App$run() event loop
  HTML/CSS/JS UI               on_message() handlers
  rdesk.js bridge              async() workers (mirai/callr)

The launcher and R are separate OS processes. They communicate via: - R to JS: app$send(“type”, payload) -> PostWebMessageAsString - JS to R: rdesk.send(“type”, payload) -> stdin JSON pipe

Zero TCP ports are opened at any point. No httpuv. No WebSocket.


SECTION 2 — IPC CONTRACT (NEVER BREAK THIS)

Every message has this exact envelope. Both directions. Always.

{
  "id":        "msg_abc123",
  "type":      "action_name",
  "version":   "1.0",
  "payload":   {},
  "timestamp": 1234567890.123
}

RULE: Never construct raw JSON manually. Always use: - R side: app$send(“type”, list(…)) or rdesk_message(“type”, list(…)) - JS side: rdesk.send(“type”, {…})

RULE: Handler return values are automatically wrapped in the envelope. Return a plain list from on_message() handlers. Never return a pre-wrapped envelope.

RULE: The result of a message of type “foo” arrives in JS as “foo_result”.

// Send from JS
rdesk.send("get_data", { filter: "cyl == 6" });

// Receive result in JS  
rdesk.on("get_data_result", function(data) {
  renderTable(data.rows, data.cols);
});

RULE: System message types start with double underscore: loading, progress, reload_ui. Never use __ prefix for app messages.


SECTION 3 — APP STRUCTURE

Every RDesk app has this exact structure. Never deviate.

MyApp/
├── app.R              <- entry point, keep thin
├── DESCRIPTION        <- app metadata
├── R/
│   ├── server.R       <- on_message() handlers (edit this most)
│   ├── data.R         <- data loading and transformation
│   └── plots.R        <- chart rendering helpers
└── www/
    ├── index.html     <- UI markup
    ├── css/
    │   └── style.css
    └── js/
        ├── app.js     <- UI event handling
        └── rdesk.js   <- IPC bridge (NEVER edit this file)

Minimal app.R

app_dir <- tryCatch(
  if (nzchar(Sys.getenv("R_BUNDLE_APP"))) getwd()
  else dirname(rstudioapi::getActiveDocumentContext()$path),
  error = function(e) getwd()
)

library(RDesk)

lapply(
  list.files(file.path(app_dir, "R"), pattern = "\\.R$", full.names = TRUE),
  source
)

app <- App$new(
  title  = "My App",
  width  = 1100L,
  height = 740L,
  www    = file.path(app_dir, "www")
)

init_handlers(app)
app$run()

RULE: app.R must stay thin. All handler logic goes in R/server.R. RULE: Always source R/ files with lapply + list.files. Never use source() on individual files by name — it breaks hot reload. RULE: The function init_handlers(app) is the contract between app.R and server.R. Always define it in server.R.


SECTION 4 — HANDLERS (on_message)

Basic handler

# In R/server.R
init_handlers <- function(app) {

  app$on_ready(function() {
    # Runs once when window is ready
    # Set menu, send initial data here
    df <- init_data()
    app$send("data_ready", rdesk_df_to_list(df))
  })

  app$on_message("get_data", function(payload) {
    df <- load_data(payload$filter)
    rdesk_df_to_list(df)
  })

}

RULE: Every handler must return a value or NULL. The return value is sent back as type_result. RULE: Never call app\(send() inside a plain on_message() handler to return the response. Return the list directly. app\)send() is for pushing unsolicited updates. RULE: Payload fields arrive as R list elements: payload$field_name. Never use payload[[“field_name”]] — use $ accessor for clarity.

Accessing payload

# JavaScript sends:
# rdesk.send("filter", { column: "cyl", value: 6, ascending: true })

app$on_message("filter", function(payload) {
  col  <- payload$column     # "cyl"
  val  <- payload$value      # 6
  asc  <- payload$ascending  # TRUE
  
  df <- mtcars[mtcars[[col]] == val, ]
  if (!asc) df <- df[nrow(df):1, ]
  
  rdesk_df_to_list(df)
})

on_ready handler

app$on_ready(function() {
  # Set native menu
  app$set_menu(list(
    File = list(
      "Open..."  = function() app$send("open_file", list()),
      "Save..."  = function() app$send("save_file", list()),
      "---",
      "Exit"     = app$quit
    ),
    Help = list(
      "About"    = function() app$toast("MyApp v1.0", type = "info")
    )
  ))
  
  # Send initial data
  app$send("init_data", list(
    data    = rdesk_df_to_list(init_data()),
    version = "1.0.0"
  ))
})

SECTION 5 — ASYNC ENGINE

RDesk has three tiers of async. Use the right one.

Tier 1 — async() wrapper (use this for 95% of cases)

app$on_message("heavy_task", async(function(payload) {
  # Runs in background mirai/callr worker
  # UI stays responsive
  # Loading overlay shown automatically
  result <- slow_computation(payload$data)
  list(result = result, n = length(result))
}, app = app, loading_message = "Computing..."))

RULE: async() captures the packages loaded at registration time and reloads them in the worker. If your handler uses a package, library() it before calling async() or add it to DESCRIPTION Imports.

RULE: Do NOT access app$ methods inside an async() worker. The App object lives in the main process. Workers cannot reach it. Return data from the worker. app$send() happens automatically.

RULE: Do NOT use global variables inside async() workers. Pass everything via payload or capture in the closure explicitly.

# WRONG - global variable access in worker
my_data <- load_data()
app$on_message("process", async(function(payload) {
  process(my_data)  # my_data not available in worker
}, app = app))

# CORRECT - pass via payload or capture explicitly
my_data <- load_data()
app$on_message("process", async(function(payload) {
  process(payload$data)  # sent from JS
}, app = app))

# OR - capture explicitly in closure
local_data <- my_data
app$on_message("process", async(function(payload) {
  process(local_data)  # captured in closure, serialised to worker
}, app = app))

Tier 2 — rdesk_async() (explicit control)

job_id <- rdesk_async(
  task     = function(data) slow_model(data),
  args     = list(data = my_data),
  on_done  = function(result) app$send("model_done", result),
  on_error = function(err)    app$toast(err$message, type = "error")
)

app$loading_start("Running model...", cancellable = TRUE, job_id = job_id)

Tier 3 — mirai direct (expert use only)

m <- mirai::mirai(
  slow_fn(x),
  slow_fn = slow_fn,
  x = my_data
)

async_progress() — real-time updates from workers

app$on_message("long_process", async(function(payload) {
  items <- payload$items
  for (i in seq_along(items)) {
    process_item(items[[i]])
    async_progress(
      value   = round(i / length(items) * 100),
      message = paste0("Processing ", i, " of ", length(items))
    )
  }
  list(done = TRUE, processed = length(items))
}, app = app, loading_message = "Starting..."))

SECTION 6 — JAVASCRIPT PATTERNS

rdesk.js API — complete reference

// Send message to R, returns Promise
rdesk.send("message_type", { key: "value" })
  .then(function(result) { /* handle result */ })
  .catch(function(err)   { /* handle error */ });

// Listen for messages pushed from R
rdesk.on("message_type", function(data) { /* handle */ });

// Run code when app is ready
rdesk.ready(function() {
  rdesk.send("get_initial_data", {});
});

// Remove a listener
rdesk.off("message_type");

Standard UI patterns

// Render a base64 chart from R
rdesk.on("chart_result", function(data) {
  document.getElementById("chart").src =
    "data:image/png;base64," + data.chart;
});

// Render a table from R
rdesk.on("data_result", function(data) {
  var head = document.getElementById("thead");
  var body = document.getElementById("tbody");
  
  head.innerHTML = "<tr>" + 
    data.cols.map(function(c) { return "<th>" + c + "</th>"; }).join("") + 
    "</tr>";
    
  body.innerHTML = data.rows.map(function(row) {
    return "<tr>" + 
      data.cols.map(function(c) { 
        return "<td>" + (row[c] !== undefined ? row[c] : "") + "</td>"; 
      }).join("") + 
      "</tr>";
  }).join("");
});

// Handle loading state
rdesk.on("__loading__", function(state) {
  var overlay = document.getElementById("loading-overlay");
  if (overlay) overlay.style.display = state.active ? "flex" : "none";
});

// Send on button click
document.getElementById("btn-refresh").addEventListener("click", function() {
  rdesk.send("get_data", { filter: getCurrentFilter() });
});

RULE: Always use rdesk.ready() to wrap any rdesk.send() calls that fire on page load. Without it the IPC bridge may not be initialised yet.

RULE: Never use fetch(), XMLHttpRequest, or WebSocket to communicate with R. Only rdesk.send() and rdesk.on().

RULE: rdesk.js is in www/js/rdesk.js. Never edit it. Never import it from a CDN — it must be the local copy.


SECTION 7 — CHARTS AND DATA

Plot to base64

# In R/plots.R
make_scatter <- function(df, x_var = "wt", y_var = "mpg") {
  p <- ggplot2::ggplot(df, ggplot2::aes(.data[[x_var]], .data[[y_var]])) +
    ggplot2::geom_point(size = 3, alpha = 0.8, colour = "#378ADD") +
    ggplot2::geom_smooth(method = "lm", se = FALSE,
                          colour = "#1D9E75", linewidth = 0.8) +
    ggplot2::theme_minimal(base_size = 13)
  rdesk_plot_to_base64(p)
}
# In handler
app$on_message("get_chart", async(function(payload) {
  df <- filter_data(payload)
  list(chart = make_scatter(df, payload$x_var, payload$y_var))
}, app = app))
// In app.js
rdesk.on("get_chart_result", function(data) {
  document.getElementById("main-chart").src =
    "data:image/png;base64," + data.chart;
});

Data frame to list

# rdesk_df_to_list() converts a data frame to JSON-serialisable list
result <- rdesk_df_to_list(mtcars)
# result$rows  -> list of row lists
# result$cols  -> character vector of column names

# In handler
app$on_message("get_table", function(payload) {
  df <- filter_data(payload)
  rdesk_df_to_list(df)
})

SECTION 8 — NATIVE FEATURES

File dialogs

# Open file
app$on_message("open_file", function(payload) {
  path <- app$dialog_open(
    title   = "Open Data File",
    filters = "CSV files (*.csv)|*.csv|All files (*.*)|*.*"
  )
  if (is.null(path)) return(list(cancelled = TRUE))
  
  df <- utils::read.csv(path, stringsAsFactors = FALSE)
  c(list(cancelled = FALSE, filename = basename(path)),
    rdesk_df_to_list(df))
})

# Save file
app$on_message("save_file", function(payload) {
  path <- app$dialog_save(
    title   = "Save Results",
    filters = "CSV files (*.csv)|*.csv",
    default = "results.csv"
  )
  if (is.null(path)) return(list(cancelled = TRUE))
  
  write.csv(reconstruct_df(payload$data), path, row.names = FALSE)
  list(cancelled = FALSE, saved_to = basename(path))
})

Toasts and notifications

app$toast("Operation complete", type = "success")  # green
app$toast("File not found",     type = "error")    # red
app$toast("Update available",   type = "info")     # blue
app$toast("Check your inputs",  type = "warning")  # amber

Loading overlay

# Manual control (use async() instead when possible)
app$loading_start("Processing data...", cancellable = TRUE, job_id = "job_1")
app$loading_progress(45)
app$loading_progress(90, message = "Almost done...")
app$loading_done()

Native menus

app$set_menu(list(
  File = list(
    "New..."     = function() app$send("new_doc", list()),
    "Open..."    = function() app$send("open_file", list()),
    "---",
    "Exit"       = app$quit
  ),
  View = list(
    "Refresh"    = function() app$send("refresh", list()),
    "Full screen"= function() app$maximize()
  ),
  Help = list(
    "Documentation" = function() {
      shell.exec("https://janakiraman-311.github.io/RDesk/")
    },
    "About"         = function() {
      app$toast("MyApp v1.0.0 -- built with RDesk", type = "info")
    }
  )
))

SECTION 9 — BUILD AND DISTRIBUTION

Build a distributable

RDesk::build_app(
  app_dir         = "path/to/MyApp",
  app_name        = "MyApp",
  out_dir         = tempdir(),        # or your dist folder
  build_installer = TRUE              # creates .exe installer
)
# Output: dist/MyApp-1.0.0-setup.exe (68-200MB depending on packages)

Detect bundle vs development mode

if (rdesk_is_bundle()) {
  # Running as distributed exe
  config_path <- file.path(getwd(), "config.json")
} else {
  # Running in development via source("app.R")
  config_path <- file.path(app_dir, "config.json")
}

Auto-update

# In app.R, before app$run()
rdesk_auto_update(
  current_version = "1.0.0",
  version_url     = "https://example.com/myapp/latest.txt",
  download_url    = "https://example.com/myapp/MyApp-setup.exe",
  app             = app
)

Host a plain text file at version_url containing only the version string e.g. “1.1.0”. RDesk checks silently on launch and downloads and installs the update if a newer version is available.


SECTION 10 — SHINY MIGRATION REFERENCE

Pattern mapping

Shiny pattern RDesk equivalent
input\(x | payload\)x inside on_message()
reactive({…}) plain R function called explicitly
renderPlot({…}) rdesk_plot_to_base64() + return list
renderTable({…}) rdesk_df_to_list() + return list
observe({…}) app\(on_message() handler | | observeEvent(input\)x)
withProgress() async() with loading_message=
downloadHandler() app\(dialog_save() in on_message() | | fileInput() | app\)dialog_open() in on_message()
updateSelectInput() app\(send("update_select", list(...)) | | session\)sendMessage()

Migration sequence

  1. Copy all pure R functions (data loading, modelling, helpers) unchanged
  2. Map every input$x to a JS rdesk.send(“x_changed”, {value: …})
  3. Map every render*() to an on_message() that returns the data
  4. Rewrite ui.R as www/index.html using plain HTML
  5. Replace renderPlot with rdesk_plot_to_base64() in handlers
  6. Replace renderTable with rdesk_df_to_list() in handlers
  7. Replace fileInput/downloadHandler with dialog_open/dialog_save
  8. Wrap slow handlers in async()
  9. Add native menu with app$set_menu() in on_ready()

Complete migration example

# SHINY server.R
server <- function(input, output, session) {
  filtered <- reactive({
    mtcars[mtcars$cyl == input$cyl_filter, ]
  })
  output$scatter <- renderPlot({
    ggplot2::ggplot(filtered(), ggplot2::aes(wt, mpg)) +
      ggplot2::geom_point()
  })
  output$table <- renderTable({ filtered() })
}
# RDESK R/server.R
init_handlers <- function(app) {

  app$on_ready(function() {
    app$send("data_ready", rdesk_df_to_list(mtcars))
  })

  app$on_message("filter_changed", async(function(payload) {
    df <- mtcars[mtcars$cyl == payload$cyl_filter, ]
    p  <- ggplot2::ggplot(df, ggplot2::aes(wt, mpg)) +
            ggplot2::geom_point()
    list(
      chart = rdesk_plot_to_base64(p),
      table = rdesk_df_to_list(df)
    )
  }, app = app))

}
// RDESK www/js/app.js
rdesk.ready(function() {
  rdesk.send("get_data", {});
});

document.getElementById("cyl-filter").addEventListener("change", function() {
  rdesk.send("filter_changed", { cyl_filter: parseInt(this.value) });
});

rdesk.on("filter_changed_result", function(data) {
  document.getElementById("chart").src =
    "data:image/png;base64," + data.chart;
  renderTable(data.table.rows, data.table.cols);
});

SECTION 11 — COMMON MISTAKES AND FIXES

Mistake 1 — calling app$ inside async() worker

# WRONG
app$on_message("task", async(function(payload) {
  result <- compute(payload)
  app$toast("Done!")      # ERROR: app not available in worker
  app$send("done", result) # ERROR: same reason
}, app = app))

# CORRECT
app$on_message("task", async(function(payload) {
  result <- compute(payload)
  list(result = result)   # return value is sent automatically
}, app = app))
# toast after completion: use on_done callback in rdesk_async()

Mistake 2 — forgetting flush(stdout())

# WRONG - in bundled mode, messages may buffer
cat(jsonlite::toJSON(msg), "\n")

# CORRECT - always flush after writing to stdout
cat(jsonlite::toJSON(msg), "\n")
flush(stdout())

# Note: app$send() handles this automatically.
# Only matters if you write raw cat() calls.

Mistake 3 — sourcing individual R files by name

# WRONG - breaks hot reload and is fragile
source("R/server.R")
source("R/data.R")

# CORRECT - sources all files, order-safe
lapply(
  list.files(file.path(app_dir, "R"), pattern = "\\.R$", full.names = TRUE),
  source
)

Mistake 4 — writing to getwd() in examples or defaults

# WRONG - CRAN policy violation
build_app(app_dir = "MyApp", out_dir = "dist")

# CORRECT - always write to tempdir() in examples
build_app(app_dir = "MyApp", out_dir = file.path(tempdir(), "dist"))

Mistake 5 — using installed.packages()

# WRONG - slow, CRAN policy violation
if ("ggplot2" %in% installed.packages()[,"Package"]) { ... }

# CORRECT
if (requireNamespace("ggplot2", quietly = TRUE)) { ... }

Mistake 6 — no on.exit() after setwd()

# WRONG
setwd(build_dir)
# ... do work ...
setwd(original_dir)  # never runs if error occurs

# CORRECT
oldwd <- getwd()
on.exit(setwd(oldwd), add = TRUE)
setwd(build_dir)

SECTION 12 — DEVELOPMENT WORKFLOW

Daily development loop

# 1. Open project in RStudio
# 2. Source the app
source("app.R")
# Window opens

# 3. Make changes to R/server.R or www/
# 4. Close window, re-source
# With hot reload (Phase 2):
# rdesk_watch(app)  # auto-reloads on file change

Testing handlers without a window

# Set CI mode to test handlers without launching a window
options(rdesk.ci_mode = TRUE)
library(RDesk)

# Handlers can be unit tested
source("R/server.R")
# test individual functions from data.R and plots.R directly

Build verification before distribution

# Always verify before sending to users
pkg <- build_app(
  app_dir  = "MyApp",
  app_name = "MyApp",
  out_dir  = tempdir()
)
# Test the output ZIP manually before building installer

SECTION 13 — QUICK REFERENCE CARD

# Create new app
RDesk::rdesk_create_app("MyApp")

# Core app setup
app <- App$new(title = "Title", width = 1100L, height = 740L, www = "www/")
app$on_ready(function() { ... })
app$on_message("type", function(payload) { list(...) })
app$run()

# Send data to UI
app$send("type", list(key = value))

# Async handler
app$on_message("type", async(function(payload) {
  list(result = compute(payload))
}, app = app, loading_message = "Working..."))

# Chart
rdesk_plot_to_base64(ggplot_object)

# Table
rdesk_df_to_list(data_frame)

# IPC message
rdesk_message("type", list(key = value))
rdesk_parse_message(json_string)

# Dialogs
app$dialog_open(title = "Open", filters = "CSV|*.csv")
app$dialog_save(title = "Save", filters = "CSV|*.csv")
app$dialog_folder(title = "Select folder")

# Notifications
app$toast("message", type = "success|error|info|warning")
app$notify("title", "body")

# Loading
app$loading_start("message", cancellable = TRUE, job_id = "id")
app$loading_progress(50)
app$loading_done()

# Jobs
rdesk_async(task, args, on_done, on_error)
rdesk_cancel_job("job_id")
rdesk_jobs_pending()

# Build
build_app(app_dir, app_name, out_dir = tempdir(), build_installer = FALSE)

# Detect mode
rdesk_is_bundle()  # TRUE in distributed app, FALSE in development

# Updates
rdesk_auto_update(current_version, version_url, download_url, app)

# JavaScript
rdesk.send("type", payload)          # returns Promise
rdesk.on("type", function(data){})   # listen for messages
rdesk.ready(function(){})            # run when app ready
rdesk.off("type")                    # remove listener

SECTION 14 — PACKAGE INFORMATION

Package:    RDesk
Version:    1.0.4
CRAN:       https://cran.r-project.org/package=RDesk
GitHub:     https://github.com/Janakiraman-311/RDesk
Docs:       https://janakiraman-311.github.io/RDesk/
Maintainer: Janakiraman G <janakiraman.bt@gmail.com>
License:    MIT
OS:         Windows 10 or later only (v1.0.x)
Requires:   Rtools44+, WebView2 Runtime

Install:    install.packages("RDesk")
Dev:        devtools::install_github("Janakiraman-311/RDesk")

END OF SKILL FILE