From 4c3d678645d2e06d700b6766a4c660d40cb8bf07 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Mon, 14 Oct 2024 22:44:29 +0100 Subject: [PATCH] copy image env to workspace --- lapdev-common/src/lib.rs | 24 +- lapdev-conductor/src/server.rs | 47 +++- lapdev-ws/src/server.rs | 112 +++------ lapdev-ws/src/service.rs | 48 ++-- pkg/image/fluxbox/Dockerfile | 7 + pkg/image/fluxbox/install.sh | 434 +++++++++++++++++++++++++++++++++ pkg/image/vnc/Dockerfile | 72 ++++++ pkg/image/vnc/install.sh | 434 +++++++++++++++++++++++++++++++++ 8 files changed, 1069 insertions(+), 109 deletions(-) create mode 100644 pkg/image/fluxbox/Dockerfile create mode 100644 pkg/image/fluxbox/install.sh create mode 100644 pkg/image/vnc/Dockerfile create mode 100644 pkg/image/vnc/install.sh diff --git a/lapdev-common/src/lib.rs b/lapdev-common/src/lib.rs index d0d8d10..673a9a9 100644 --- a/lapdev-common/src/lib.rs +++ b/lapdev-common/src/lib.rs @@ -143,14 +143,18 @@ pub struct RepoContent { #[derive(Serialize, Deserialize, Debug, Clone)] pub enum RepoBuildOutput { Compose(Vec), - Image(String), + Image { + image: String, + info: ContainerImageInfo, + }, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct RepoComposeService { pub name: String, pub image: String, - pub env: Vec<(String, String)>, + pub env: Vec, + pub info: ContainerImageInfo, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -351,7 +355,7 @@ pub struct CreateWorkspaceRequest { pub image: String, pub ssh_public_key: String, pub repo_name: String, - pub env: Vec<(String, String)>, + pub env: Vec, pub cpus: CpuCore, pub memory: usize, pub disk: usize, @@ -503,7 +507,7 @@ pub struct Container { pub id: String, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ContainerPortBinding { #[serde(rename = "HostIp")] pub host_ip: String, @@ -511,7 +515,7 @@ pub struct ContainerPortBinding { pub host_port: String, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ContainerConfig { #[serde(rename = "Hostname")] pub hostname: String, @@ -521,7 +525,7 @@ pub struct ContainerConfig { pub env: Option>, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct ContainerImageConfig { #[serde(rename = "Hostname")] pub hostname: String, @@ -531,11 +535,13 @@ pub struct ContainerImageConfig { pub entrypoint: Option>, #[serde(rename = "Cmd")] pub cmd: Option>, + #[serde(rename = "Env")] + pub env: Option>, #[serde(rename = "ExposedPorts")] pub exposed_ports: Option>>, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ContainerHostConfig { #[serde(rename = "PortBindings")] pub port_bindings: HashMap>, @@ -543,7 +549,7 @@ pub struct ContainerHostConfig { pub binds: Vec, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ContainerInfo { #[serde(rename = "Config")] pub config: ContainerConfig, @@ -551,7 +557,7 @@ pub struct ContainerInfo { pub host_config: ContainerHostConfig, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct ContainerImageInfo { #[serde(rename = "Id")] pub id: String, diff --git a/lapdev-conductor/src/server.rs b/lapdev-conductor/src/server.rs index 3be0ece..be7f032 100644 --- a/lapdev-conductor/src/server.rs +++ b/lapdev-conductor/src/server.rs @@ -1682,21 +1682,40 @@ impl Conductor { ws_client: &WorkspaceServiceClient, machine_type: &entities::machine_type::Model, ) -> Result<(), ApiError> { + let flatten_env = env + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>(); let (is_compose, images) = match &output { RepoBuildOutput::Compose(services) => ( true, services .iter() - .map(|s| (Some(s.name.clone()), s.image.clone(), s.env.clone())) + .map(|s| { + ( + Some(s.name.clone()), + s.image.clone(), + s.info.config.env.clone().unwrap_or_default(), + s.env.clone(), + ) + }) .collect(), ), - RepoBuildOutput::Image(tag) => (false, vec![(None, tag.clone(), Vec::new())]), + RepoBuildOutput::Image { image, info } => ( + false, + vec![( + None, + image.clone(), + info.config.env.clone().unwrap_or_default(), + vec![], + )], + ), }; let build_output = serde_json::to_string(&output)?; let cores: CpuCore = serde_json::from_str(&ws.cores)?; - for (i, (service, tag, image_env)) in images.into_iter().enumerate() { + for (i, (service, tag, image_env, service_env)) in images.into_iter().enumerate() { let workspace_name = if let Some(service) = service.clone() { if i > 0 { format!("{}-{service}", ws.name) @@ -1711,8 +1730,18 @@ impl Conductor { } else { self.generate_key_pair()? }; - let mut env = env.clone(); - env.extend_from_slice(&image_env); + let mut ws_env = service_env.clone(); + ws_env.extend_from_slice(&flatten_env); + + let mut all_env = image_env + .iter() + .chain(service_env.iter()) + .filter_map(|env| { + let (key, value) = env.split_once('=')?; + Some((key.to_string(), value.to_string())) + }) + .collect::>(); + all_env.extend_from_slice(&env); ws_client .create_workspace( long_running_context(), @@ -1727,7 +1756,7 @@ impl Conductor { image: tag, ssh_public_key: ssh_public_key.clone(), repo_name: ws.repo_name.clone(), - env: env.clone(), + env: ws_env.clone(), cpus: cores.clone(), memory: machine_type.memory as usize, disk: machine_type.disk as usize, @@ -1796,7 +1825,7 @@ impl Conductor { prebuild_id: ActiveValue::Set(prebuild_id), build_output: ActiveValue::Set(Some(build_output.clone())), is_compose: ActiveValue::Set(is_compose), - env: ActiveValue::Set(serde_json::to_string(&env).ok()), + env: ActiveValue::Set(serde_json::to_string(&all_env).ok()), usage_id: ActiveValue::Set(Some(usage.id)), ..Default::default() } @@ -1831,7 +1860,7 @@ impl Conductor { last_inactivity: ActiveValue::Set(None), auto_stop: ActiveValue::Set(ws.auto_stop), auto_start: ActiveValue::Set(ws.auto_start), - env: ActiveValue::Set(serde_json::to_string(&env).ok()), + env: ActiveValue::Set(serde_json::to_string(&all_env).ok()), build_output: ActiveValue::Set(Some(build_output.clone())), is_compose: ActiveValue::Set(is_compose), compose_parent: ActiveValue::Set(Some(ws.id)), @@ -2167,7 +2196,7 @@ impl Conductor { RepoBuildOutput::Compose(services) => { services.into_iter().map(|s| s.image).collect() } - RepoBuildOutput::Image(tag) => vec![tag], + RepoBuildOutput::Image { image, .. } => vec![image], } } else { Vec::new() diff --git a/lapdev-ws/src/server.rs b/lapdev-ws/src/server.rs index 720516a..b9cb5ab 100644 --- a/lapdev-ws/src/server.rs +++ b/lapdev-ws/src/server.rs @@ -423,9 +423,8 @@ driver = "overlay" pub async fn get_devcontainer( &self, - info: &RepoBuildInfo, + folder: &Path, ) -> Result, ApiError> { - let folder = PathBuf::from(self.build_repo_folder(info)); let devcontainer_folder_path = folder.join(".devcontainer").join("devcontainer.json"); let devcontainer_root_path = folder.join(".devcontainer.json"); let (cwd, file_path) = if tokio::fs::try_exists(&devcontainer_folder_path) @@ -437,7 +436,7 @@ driver = "overlay" .await .unwrap_or(false) { - (folder, devcontainer_root_path) + (folder.to_path_buf(), devcontainer_root_path) } else { return Ok(None); }; @@ -455,7 +454,7 @@ driver = "overlay" context: &Path, dockerfile_content: &str, tag: &str, - ) -> Result<(), ApiError> { + ) -> Result { let temp = tempfile::NamedTempFile::new()?.into_temp_path(); { let mut temp_docker_file = tokio::fs::File::create(&temp).await?; @@ -554,7 +553,9 @@ driver = "overlay" let _ = tokio::fs::remove_file(&install_script_path).await; let _ = tokio::fs::remove_file(&lapdev_guest_agent_path).await; - Ok(()) + let image_info = self.container_image_info(&info.osuser, tag).await?; + + Ok(image_info) } pub async fn build_container_image_from_base( @@ -564,39 +565,9 @@ driver = "overlay" cwd: &Path, image: &str, tag: &str, - ) -> Result<(), ApiError> { - let _ = self - .pull_container_image(conductor_client, &info.osuser, image, &info.target) - .await; - let image_info = self.container_image_info(&info.osuser, image).await?; - + ) -> Result { let context = cwd.to_path_buf(); - let mut dockerfile_content = format!("FROM {image}\n"); - if let Some(entrypoint) = image_info.config.entrypoint { - if !entrypoint.is_empty() { - if let Ok(entrypoint) = serde_json::to_string(&entrypoint) { - dockerfile_content += "ENTRYPOINT "; - dockerfile_content += &entrypoint; - dockerfile_content += "\n"; - } - } - } - if let Some(cmd) = image_info.config.cmd { - if !cmd.is_empty() { - if let Ok(cmd) = serde_json::to_string(&cmd) { - dockerfile_content += "CMD "; - dockerfile_content += &cmd; - dockerfile_content += "\n"; - } - } - } - if let Some(ports) = image_info.config.exposed_ports { - for port in ports.keys() { - dockerfile_content += "EXPOSE "; - dockerfile_content += port; - dockerfile_content += "\n"; - } - } + let dockerfile_content = format!("FROM {image}\n"); self.do_build_container_image( conductor_client, @@ -606,8 +577,7 @@ driver = "overlay" &dockerfile_content, tag, ) - .await?; - Ok(()) + .await } pub async fn pull_container_image( @@ -662,7 +632,7 @@ driver = "overlay" cwd: &Path, build: &AdvancedBuildStep, tag: &str, - ) -> Result<(), ApiError> { + ) -> Result { let context = cwd.join(&build.context); let dockerfile = build.dockerfile.as_deref().unwrap_or("Dockerfile"); let dockerfile = context.join(dockerfile); @@ -678,29 +648,15 @@ driver = "overlay" &dockerfile_content, tag, ) - .await?; - Ok(()) + .await } - fn compose_service_env( - &self, - service: &docker_compose_types::Service, - ) -> Vec<(String, String)> { + fn compose_service_env(&self, service: &docker_compose_types::Service) -> Vec { match &service.environment { - docker_compose_types::Environment::List(list) => list - .iter() - .filter_map(|s| { - let parts: Vec = s.splitn(2, '=').map(|s| s.to_string()).collect(); - if parts.len() == 2 { - Some((parts[0].clone(), parts[1].clone())) - } else { - None - } - }) - .collect(), + docker_compose_types::Environment::List(list) => list.clone(), docker_compose_types::Environment::KvPair(pair) => pair .iter() - .filter_map(|(key, value)| Some((key.to_string(), format!("{}", value.as_ref()?)))) + .filter_map(|(key, value)| Some(format!("{key}={}", value.as_ref()?))) .collect(), } } @@ -712,7 +668,7 @@ driver = "overlay" cwd: &Path, service: &docker_compose_types::Service, tag: &str, - ) -> Result<(), ApiError> { + ) -> Result { if let Some(build) = &service.build_ { let build = match build { BuildStep::Simple(context) => AdvancedBuildStep { @@ -722,16 +678,15 @@ driver = "overlay" BuildStep::Advanced(build) => build.to_owned(), }; self.build_container_image(conductor_client, info, cwd, &build, tag) - .await?; + .await } else if let Some(image) = &service.image { self.build_container_image_from_base(conductor_client, info, cwd, image, tag) - .await?; + .await } else { return Err(ApiError::RepositoryInvalid( "can't find image or build in this compose service".to_string(), )); } - Ok(()) } pub async fn build_compose( @@ -753,13 +708,15 @@ driver = "overlay" for (name, service) in compose.services.0 { if let Some(service) = service { let tag = format!("{tag}:{name}"); - self.build_compose_service(conductor_client, info, cwd, &service, &tag) + let info = self + .build_compose_service(conductor_client, info, cwd, &service, &tag) .await?; let env = self.compose_service_env(&service); services.push(RepoComposeService { name, image: tag, env, + info, }); } } @@ -835,7 +792,7 @@ driver = "overlay" } } } - RepoBuildOutput::Image(tag) => { + RepoBuildOutput::Image { image, .. } => { let cmd = match cmd { DevContainerLifeCycleCmd::Simple(cmd) => { DevContainerCmd::Simple(cmd.to_string()) @@ -845,7 +802,7 @@ driver = "overlay" return Err(anyhow!("can't use object cmd for non compose")) } }; - self.run_devcontainer_command(conductor_client, repo, tag, &cmd) + self.run_devcontainer_command(conductor_client, repo, image, &cmd) .await?; } } @@ -946,9 +903,12 @@ driver = "overlay" conductor_client: &ConductorServiceClient, ) -> Result { self.prepare_repo(info).await?; - let (cwd, config) = self.get_devcontainer(info).await?.ok_or_else(|| { - ApiError::RepositoryInvalid("repo doesn't have devcontainer configured".to_string()) - })?; + let (cwd, config) = self + .get_devcontainer(&PathBuf::from(self.build_repo_folder(info))) + .await? + .ok_or_else(|| { + ApiError::RepositoryInvalid("repo doesn't have devcontainer configured".to_string()) + })?; let tag = self.repo_target_image_tag(&info.target); let output = if let Some(compose_file) = &config.docker_compose_file { self.build_compose(conductor_client, info, &cwd.join(compose_file), &tag) @@ -959,13 +919,21 @@ driver = "overlay" dockerfile: build.dockerfile.to_owned(), ..Default::default() }; - self.build_container_image(conductor_client, info, &cwd, &build, &tag) + let info = self + .build_container_image(conductor_client, info, &cwd, &build, &tag) .await?; - RepoBuildOutput::Image(tag.clone()) + RepoBuildOutput::Image { + image: tag.clone(), + info, + } } else if let Some(image) = config.image.as_ref() { - self.build_container_image_from_base(conductor_client, info, &cwd, image, &tag) + let info = self + .build_container_image_from_base(conductor_client, info, &cwd, image, &tag) .await?; - RepoBuildOutput::Image(tag.clone()) + RepoBuildOutput::Image { + image: tag.clone(), + info, + } } else { return Err(ApiError::RepositoryInvalid( "devcontainer doesn't have any image information".to_string(), diff --git a/lapdev-ws/src/service.rs b/lapdev-ws/src/service.rs index 3db6113..6a5babb 100644 --- a/lapdev-ws/src/service.rs +++ b/lapdev-ws/src/service.rs @@ -9,10 +9,11 @@ use bytes::Bytes; use http_body_util::{BodyExt, Full}; use hyperlocal::Uri; use lapdev_common::{ - BuildTarget, Container, ContainerInfo, CpuCore, CreateWorkspaceRequest, DeleteWorkspaceRequest, - GitBranch, NewContainer, NewContainerEndpointSettings, NewContainerHostConfig, - NewContainerNetwork, NewContainerNetworkingConfig, PrebuildInfo, ProjectRequest, RepoBuildInfo, - RepoBuildOutput, RepoContent, RepoContentPosition, StartWorkspaceRequest, StopWorkspaceRequest, + BuildTarget, Container, ContainerImageInfo, ContainerInfo, CpuCore, CreateWorkspaceRequest, + DeleteWorkspaceRequest, GitBranch, NewContainer, NewContainerEndpointSettings, + NewContainerHostConfig, NewContainerNetwork, NewContainerNetworkingConfig, PrebuildInfo, + ProjectRequest, RepoBuildInfo, RepoBuildOutput, RepoContent, RepoContentPosition, + StartWorkspaceRequest, StopWorkspaceRequest, }; use lapdev_guest_agent::{LAPDEV_CMDS, LAPDEV_IDE_CMDS, LAPDEV_SSH_PUBLIC_KEY}; use lapdev_rpc::{ @@ -159,21 +160,31 @@ impl WorkspaceService for WorkspaceRpcService { socket, &format!("/containers/create?name={}", ws_req.workspace_name), ); - let mut env: Vec = ws_req - .env - .into_iter() - .map(|(name, value)| format!("{name}={value}")) - .collect(); + let mut env = ws_req.env.clone(); env.extend_from_slice(&[ format!("{LAPDEV_SSH_PUBLIC_KEY}={}", ws_req.ssh_public_key), format!("{LAPDEV_IDE_CMDS}={}", ide_cmds), format!("{LAPDEV_CMDS}={}", cmds), ]); + let workspace_folder = self + .server + .workspace_folder(&ws_req.osuser, &ws_req.volume_name); + let mut exposed_ports = image_info.config.exposed_ports.unwrap_or_default(); exposed_ports.insert("22/tcp".to_string(), HashMap::new()); exposed_ports.insert("30000/tcp".to_string(), HashMap::new()); + if let Ok(Some((_, config))) = self + .server + .get_devcontainer(&PathBuf::from(&workspace_folder).join(&ws_req.repo_name)) + .await + { + for port in config.forward_ports { + exposed_ports.insert(format!("{port}/tcp"), HashMap::new()); + } + } + let mut body = NewContainer { hostname: ws_req.workspace_name.clone(), user: "root".to_string(), @@ -184,11 +195,7 @@ impl WorkspaceService for WorkspaceRpcService { working_dir: format!("/workspaces/{}", ws_req.repo_name), host_config: NewContainerHostConfig { publish_all_ports: true, - binds: vec![format!( - "{}:/workspaces", - self.server - .workspace_folder(&ws_req.osuser, &ws_req.volume_name,) - )], + binds: vec![format!("{workspace_folder}:/workspaces",)], cpu_period: None, cpu_quota: None, cpuset_cpus: None, @@ -460,7 +467,10 @@ impl WorkspaceService for WorkspaceRpcService { let image_name = self.server.get_default_image(&info).await; let image = format!("ghcr.io/lapce/lapdev-devcontainer-{image_name}:latest"); tracing::debug!("build repo pick default image {image_name}"); - RepoBuildOutput::Image(image) + RepoBuildOutput::Image { + image, + info: ContainerImageInfo::default(), + } } async fn copy_prebuild_image( @@ -473,7 +483,7 @@ impl WorkspaceService for WorkspaceRpcService { ) -> Result<(), ApiError> { let images = match output { RepoBuildOutput::Compose(services) => services.into_iter().map(|s| s.image).collect(), - RepoBuildOutput::Image(tag) => vec![tag], + RepoBuildOutput::Image { image, .. } => vec![image], }; let prebuild_folder = @@ -597,7 +607,7 @@ impl WorkspaceService for WorkspaceRpcService { let images = match output { RepoBuildOutput::Compose(services) => services.into_iter().map(|s| s.image).collect(), - RepoBuildOutput::Image(tag) => vec![tag], + RepoBuildOutput::Image { image, .. } => vec![image], }; for image in images { @@ -645,7 +655,7 @@ impl WorkspaceService for WorkspaceRpcService { RepoBuildOutput::Compose(services) => { services.into_iter().map(|s| s.image).collect() } - RepoBuildOutput::Image(tag) => vec![tag], + RepoBuildOutput::Image { image, .. } => vec![image], }; for image in images { @@ -680,7 +690,7 @@ impl WorkspaceService for WorkspaceRpcService { let mut files = vec!["repo.tar.zst".to_string()]; let images = match output { RepoBuildOutput::Compose(services) => services.into_iter().map(|s| s.image).collect(), - RepoBuildOutput::Image(tag) => vec![tag], + RepoBuildOutput::Image { image, .. } => vec![image], }; for image in images { if image.starts_with("ghcr.io") { diff --git a/pkg/image/fluxbox/Dockerfile b/pkg/image/fluxbox/Dockerfile new file mode 100644 index 0000000..57cf1f7 --- /dev/null +++ b/pkg/image/fluxbox/Dockerfile @@ -0,0 +1,7 @@ +FROM debian:12 + +COPY ./pkg/image/vnc/install.sh /tmp/install.sh +RUN chmod +x /tmp/install.sh +RUN /tmp/install.sh +ENV DISPLAY=:1 +ENTRYPOINT /usr/local/share/desktop-init.sh diff --git a/pkg/image/fluxbox/install.sh b/pkg/image/fluxbox/install.sh new file mode 100644 index 0000000..2732586 --- /dev/null +++ b/pkg/image/fluxbox/install.sh @@ -0,0 +1,434 @@ +#!/usr/bin/env bash + +NOVNC_VERSION="${NOVNCVERSION:-"1.2.0"}" # TODO: Add in a 'latest' auto-detect and swap name to 'version' +VNC_PASSWORD=${PASSWORD:-"vscode"} +if [ "$VNC_PASSWORD" = "noPassword" ]; then + unset VNC_PASSWORD +fi +NOVNC_PORT="${WEBPORT:-6080}" +VNC_PORT="${VNCPORT:-5901}" + +INSTALL_NOVNC="${INSTALL_NOVNC:-"true"}" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" + +WEBSOCKETIFY_VERSION=0.10.0 + +package_list=" + tigervnc-standalone-server \ + tigervnc-common \ + fluxbox \ + dbus-x11 \ + x11-utils \ + x11-xserver-utils \ + xdg-utils \ + fbautostart \ + at-spi2-core \ + xterm \ + eterm \ + nautilus\ + mousepad \ + seahorse \ + gnome-icon-theme \ + gnome-keyring \ + libx11-dev \ + libxkbfile-dev \ + libsecret-1-dev \ + libgbm-dev \ + libnotify4 \ + libnss3 \ + libxss1 \ + xfonts-base \ + xfonts-terminus \ + fonts-noto \ + fonts-wqy-microhei \ + fonts-droid-fallback \ + htop \ + ncdu \ + curl \ + ca-certificates\ + unzip \ + nano \ + locales" + +# Packages to attempt to install if essential tools are missing (ie: vncpasswd). +# This is useful, at least, for Ubuntu 22.04 (jammy) +package_list_additional=" + tigervnc-tools" + +set -e + +# Clean up +rm -rf /var/lib/apt/lists/* + +if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 +fi + +# Determine the appropriate non-root user +if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + USERNAME="" + POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do + if id -u ${CURRENT_USER} > /dev/null 2>&1; then + USERNAME=${CURRENT_USER} + break + fi + done + if [ "${USERNAME}" = "" ]; then + USERNAME=root + fi +elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then + USERNAME=root +fi +# Add default Fluxbox config files if none are already present +fluxbox_apps="$(cat \ +<< 'EOF' +[transient] (role=GtkFileChooserDialog) + [Dimensions] {70% 70%} + [Position] (CENTER) {0 0} +[end] +EOF +)" + +fluxbox_init="$(cat \ +<< 'EOF' +session.configVersion: 13 +session.menuFile: ~/.fluxbox/menu +session.keyFile: ~/.fluxbox/keys +session.styleFile: /usr/share/fluxbox/styles/qnx-photon +session.screen0.workspaces: 1 +session.screen0.workspacewarping: false +session.screen0.toolbar.widthPercent: 100 +session.screen0.strftimeFormat: %a %l:%M %p +session.screen0.toolbar.tools: RootMenu, clock, iconbar, systemtray +session.screen0.workspaceNames: One, +EOF +)" + +fluxbox_menu="$(cat \ +<< 'EOF' +[begin] ( Application Menu ) + [exec] (File Manager) { nautilus ~ } <> + [exec] (Text Editor) { mousepad } <> + [exec] (Terminal) { tilix -w ~ -e $(readlink -f /proc/$$/exe) -il } <> + [exec] (Web Browser) { x-www-browser --disable-dev-shm-usage } <> + [submenu] (System) {} + [exec] (Set Resolution) { tilix -t "Set Resolution" -e bash /usr/local/bin/set-resolution } <> + [exec] (Edit Application Menu) { mousepad ~/.fluxbox/menu } <> + [exec] (Passwords and Keys) { seahorse } <> + [exec] (Top Processes) { tilix -t "Top" -e htop } <> + [exec] (Disk Utilization) { tilix -t "Disk Utilization" -e ncdu / } <> + [exec] (Editres) {editres} <> + [exec] (Xfontsel) {xfontsel} <> + [exec] (Xkill) {xkill} <> + [exec] (Xrefresh) {xrefresh} <> + [end] + [config] (Configuration) + [workspaces] (Workspaces) +[end] +EOF +)" + +# Copy config files if the don't already exist +copy_fluxbox_config() { + local target_dir="$1" + mkdir -p "${target_dir}/.fluxbox" + touch "${target_dir}/.Xmodmap" + if [ ! -e "${target_dir}/.fluxbox/apps" ]; then + echo "${fluxbox_apps}" > "${target_dir}/.fluxbox/apps" + fi + if [ ! -e "${target_dir}/.fluxbox/init" ]; then + echo "${fluxbox_init}" > "${target_dir}/.fluxbox/init" + fi + if [ ! -e "${target_dir}/.fluxbox/menu" ]; then + echo "${fluxbox_menu}" > "${target_dir}/.fluxbox/menu" + fi +} + +apt_get_update() +{ + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi +} + +# Checks if packages are installed and installs them if not +check_packages() { + if ! dpkg -s "$@" > /dev/null 2>&1; then + apt_get_update + apt-get -y install --no-install-recommends "$@" + fi +} + +########################## +# Install starts here # +########################## + +# Ensure apt is in non-interactive to avoid prompts +export DEBIAN_FRONTEND=noninteractive + +apt_get_update + +# On older Ubuntu, Tilix is in a PPA. on Debian stretch its in backports. +if [[ -z $(apt-cache --names-only search ^tilix$) ]]; then + . /etc/os-release + if [ "${ID}" = "ubuntu" ]; then + check_packages apt-transport-https software-properties-common + add-apt-repository -y ppa:webupd8team/terminix + elif [ "${VERSION_CODENAME}" = "stretch" ]; then + echo "deb http://deb.debian.org/debian stretch-backports main" > /etc/apt/sources.list.d/stretch-backports.list + fi + apt-get update + if [[ -z $(apt-cache --names-only search ^tilix$) ]]; then + echo "(!) WARNING: Tilix not available on ${ID} ${VERSION_CODENAME} architecture $(uname -m). Skipping." + else + package_list="${package_list} tilix" + fi +else + package_list="${package_list} tilix" +fi + +# Install X11, fluxbox and VS Code dependencies +check_packages ${package_list} + +# if Ubuntu-24.04, noble(numbat) found, then will install libasound2-dev instead of libasound2. +# this change is temporary, https://packages.ubuntu.com/noble/libasound2 will switch to libasound2 once it is available for Ubuntu-24.04, noble(numbat) +. /etc/os-release +if [ "${ID}" = "ubuntu" ] && [ "${VERSION_CODENAME}" = "noble" ]; then + echo "Ubuntu 24.04, Noble(Numbat) detected. Installing libasound2-dev package..." + check_packages "libasound2-dev" +else + check_packages "libasound2" +fi + +# On newer versions of Ubuntu (22.04), +# we need an additional package that isn't provided in earlier versions +if ! type vncpasswd > /dev/null 2>&1; then + check_packages ${package_list_additional} +fi + +# Install Emoji font if available in distro - Available in Debian 10+, Ubuntu 18.04+ +if dpkg-query -W fonts-noto-color-emoji > /dev/null 2>&1 && ! dpkg -s fonts-noto-color-emoji > /dev/null 2>&1; then + apt-get -y install --no-install-recommends fonts-noto-color-emoji +fi + +# Check at least one locale exists +if ! grep -o -E '^\s*en_US.UTF-8\s+UTF-8' /etc/locale.gen > /dev/null; then + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen + locale-gen +fi + +# Install the Cascadia Code fonts - https://github.com/microsoft/cascadia-code +if [ ! -d "/usr/share/fonts/truetype/cascadia" ]; then + curl -sSL https://github.com/microsoft/cascadia-code/releases/download/v2008.25/CascadiaCode-2008.25.zip -o /tmp/cascadia-fonts.zip + unzip /tmp/cascadia-fonts.zip -d /tmp/cascadia-fonts + mkdir -p /usr/share/fonts/truetype/cascadia + mv /tmp/cascadia-fonts/ttf/* /usr/share/fonts/truetype/cascadia/ + rm -rf /tmp/cascadia-fonts.zip /tmp/cascadia-fonts +fi + +# Install noVNC +if [ "${INSTALL_NOVNC}" = "true" ] && [ ! -d "/usr/local/novnc" ]; then + mkdir -p /usr/local/novnc + curl -sSL https://github.com/novnc/noVNC/archive/v${NOVNC_VERSION}.zip -o /tmp/novnc-install.zip + unzip /tmp/novnc-install.zip -d /usr/local/novnc + cp /usr/local/novnc/noVNC-${NOVNC_VERSION}/vnc.html /usr/local/novnc/noVNC-${NOVNC_VERSION}/index.html + curl -sSL https://github.com/novnc/websockify/archive/v${WEBSOCKETIFY_VERSION}.zip -o /tmp/websockify-install.zip + unzip /tmp/websockify-install.zip -d /usr/local/novnc + ln -s /usr/local/novnc/websockify-${WEBSOCKETIFY_VERSION} /usr/local/novnc/noVNC-${NOVNC_VERSION}/utils/websockify + rm -f /tmp/websockify-install.zip /tmp/novnc-install.zip + + # Install noVNC dependencies and use them. + check_packages python3-minimal python3-numpy + sed -i -E 's/^python /python3 /' /usr/local/novnc/websockify-${WEBSOCKETIFY_VERSION}/run +fi + +# Set up folders for scripts and init files +mkdir -p /var/run/dbus /usr/local/etc/vscode-dev-containers/ + +# Script to change resolution of desktop +cat << EOF > /usr/local/bin/set-resolution +#!/bin/bash +RESOLUTION=\${1:-\${VNC_RESOLUTION:-1920x1080}} +DPI=\${2:-\${VNC_DPI:-96}} +IGNORE_ERROR=\${3:-"false"} +if [ -z "\$1" ]; then + echo -e "**Current Settings **\n" + xrandr + echo -n -e "\nEnter new resolution (WIDTHxHEIGHT, blank for \${RESOLUTION}, Ctrl+C to abort).\n> " + read NEW_RES + if [ "\${NEW_RES}" != "" ]; then + RESOLUTION=\${NEW_RES} + fi + if ! echo "\${RESOLUTION}" | grep -E '[0-9]+x[0-9]+' > /dev/null; then + echo -e "\nInvalid resolution format!\n" + exit 1 + fi + if [ -z "\$2" ]; then + echo -n -e "\nEnter new DPI (blank for \${DPI}, Ctrl+C to abort).\n> " + read NEW_DPI + if [ "\${NEW_DPI}" != "" ]; then + DPI=\${NEW_DPI} + fi + fi +fi + +xrandr --fb \${RESOLUTION} --dpi \${DPI} > /dev/null 2>&1 + +if [ \$? -ne 0 ] && [ "\${IGNORE_ERROR}" != "true" ]; then + echo -e "\nFAILED TO SET RESOLUTION!\n" + exit 1 +fi + +echo -e "\nSuccess!\n" +EOF + +# Container ENTRYPOINT script +cat << EOF > /usr/local/share/desktop-init.sh +#!/bin/bash + +user_name="${USERNAME}" +group_name="$(id -gn ${USERNAME})" +LOG=/tmp/container-init.log + +export DBUS_SESSION_BUS_ADDRESS="\${DBUS_SESSION_BUS_ADDRESS:-"autolaunch:"}" +export DISPLAY="\${DISPLAY:-:1}" +export VNC_RESOLUTION="\${VNC_RESOLUTION:-1440x768x16}" +export LANG="\${LANG:-"en_US.UTF-8"}" +export LANGUAGE="\${LANGUAGE:-"en_US.UTF-8"}" + +# Execute the command it not already running +startInBackgroundIfNotRunning() +{ + log "Starting \$1." + echo -e "\n** \$(date) **" | sudoIf tee -a /tmp/\$1.log > /dev/null + if ! pgrep -x \$1 > /dev/null; then + keepRunningInBackground "\$@" + while ! pgrep -x \$1 > /dev/null; do + sleep 1 + done + log "\$1 started." + else + echo "\$1 is already running." | sudoIf tee -a /tmp/\$1.log > /dev/null + log "\$1 is already running." + fi +} + +# Keep command running in background +keepRunningInBackground() +{ + (\$2 bash -c "while :; do echo [\\\$(date)] Process started.; \$3; echo [\\\$(date)] Process exited!; sleep 5; done 2>&1" | sudoIf tee -a /tmp/\$1.log > /dev/null & echo "\$!" | sudoIf tee /tmp/\$1.pid > /dev/null) +} + +# Use sudo to run as root when required +sudoIf() +{ + if [ "\$(id -u)" -ne 0 ]; then + sudo "\$@" + else + "\$@" + fi +} + +# Use sudo to run as non-root user if not already running +sudoUserIf() +{ + if [ "\$(id -u)" -eq 0 ] && [ "\${user_name}" != "root" ]; then + sudo -u \${user_name} "\$@" + else + "\$@" + fi +} + +# Log messages +log() +{ + echo -e "[\$(date)] \$@" | sudoIf tee -a \$LOG > /dev/null +} + +log "** SCRIPT START **" + +# Start dbus. +log 'Running "/etc/init.d/dbus start".' +if [ -f "/var/run/dbus/pid" ] && ! pgrep -x dbus-daemon > /dev/null; then + sudoIf rm -f /var/run/dbus/pid +fi +sudoIf /etc/init.d/dbus start 2>&1 | sudoIf tee -a /tmp/dbus-daemon-system.log > /dev/null +while ! pgrep -x dbus-daemon > /dev/null; do + sleep 1 +done + +# Startup tigervnc server and fluxbox +sudoIf rm -rf /tmp/.X11-unix /tmp/.X*-lock +mkdir -p /tmp/.X11-unix +sudoIf chmod 1777 /tmp/.X11-unix +sudoIf chown root:\${group_name} /tmp/.X11-unix +if [ "\$(echo "\${VNC_RESOLUTION}" | tr -cd 'x' | wc -c)" = "1" ]; then VNC_RESOLUTION=\${VNC_RESOLUTION}x16; fi +screen_geometry="\${VNC_RESOLUTION%*x*}" +screen_depth="\${VNC_RESOLUTION##*x}" + +# Check if VNC_PASSWORD is set and use the appropriate command +common_options="tigervncserver \${DISPLAY} -geometry \${screen_geometry} -depth \${screen_depth} -rfbport ${VNC_PORT} -dpi \${VNC_DPI:-96} -localhost -desktop fluxbox -fg" + +if [ -n "\${VNC_PASSWORD+x}" ]; then + startInBackgroundIfNotRunning "Xtigervnc" sudoUserIf "\${common_options} -passwd /usr/local/etc/vscode-dev-containers/vnc-passwd" +else + startInBackgroundIfNotRunning "Xtigervnc" sudoUserIf "\${common_options} -SecurityTypes None" +fi + +# Spin up noVNC if installed and not running. +if [ -d "/usr/local/novnc" ] && [ "\$(ps -ef | grep /usr/local/novnc/noVNC*/utils/launch.sh | grep -v grep)" = "" ]; then + keepRunningInBackground "noVNC" sudoIf "/usr/local/novnc/noVNC*/utils/launch.sh --listen ${NOVNC_PORT} --vnc localhost:${VNC_PORT}" + log "noVNC started." +else + log "noVNC is already running or not installed." +fi + +# Run whatever was passed in +if [ -n "$1" ]; then + log "Executing \"\$@\"." + exec "$@" +else + log "No command provided to execute." +fi +log "** SCRIPT EXIT **" +EOF + +if [ -n "${VNC_PASSWORD+x}" ]; then + echo "${VNC_PASSWORD}" | vncpasswd -f > /usr/local/etc/vscode-dev-containers/vnc-passwd +fi +chmod +x /usr/local/share/desktop-init.sh /usr/local/bin/set-resolution + +# Set up fluxbox config +copy_fluxbox_config "/root" +if [ "${USERNAME}" != "root" ]; then + copy_fluxbox_config "/home/${USERNAME}" + chown -R ${USERNAME} /home/${USERNAME}/.Xmodmap /home/${USERNAME}/.fluxbox +fi + +# Clean up +rm -rf /var/lib/apt/lists/* + +# Determine the message based on whether VNC_PASSWORD is set +if [ -n "${VNC_PASSWORD+x}" ]; then + PASSWORD_MESSAGE="In both cases, use the password \"${VNC_PASSWORD}\" when connecting" +else + PASSWORD_MESSAGE="In both cases, no password is required." +fi + +# Display the message +cat << EOF + + +You now have a working desktop! Connect to in one of the following ways: + +- Forward port ${NOVNC_PORT} and use a web browser to start the noVNC client (recommended) +- Forward port ${VNC_PORT} using VS Code client and connect using a VNC Viewer + +${PASSWORD_MESSAGE} + +(*) Done! + +EOF \ No newline at end of file diff --git a/pkg/image/vnc/Dockerfile b/pkg/image/vnc/Dockerfile new file mode 100644 index 0000000..ab8c648 --- /dev/null +++ b/pkg/image/vnc/Dockerfile @@ -0,0 +1,72 @@ +FROM debian:12 + +RUN apt-get update && apt-mark hold iptables && \ + env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + dbus-x11 \ + psmisc \ + xdg-utils \ + x11-xserver-utils \ + x11-utils && \ + env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + xfce4 && \ + env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + libgtk-3-bin \ + libpulse0 \ + mousepad \ + xfce4-notifyd \ + xfce4-taskmanager \ + xfce4-terminal && \ + env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + xfce4-battery-plugin \ + xfce4-clipman-plugin \ + xfce4-cpufreq-plugin \ + xfce4-cpugraph-plugin \ + xfce4-diskperf-plugin \ + xfce4-datetime-plugin \ + xfce4-fsguard-plugin \ + xfce4-genmon-plugin \ + xfce4-indicator-plugin \ + xfce4-netload-plugin \ + xfce4-places-plugin \ + xfce4-sensors-plugin \ + xfce4-smartbookmark-plugin \ + xfce4-systemload-plugin \ + xfce4-timer-plugin \ + xfce4-verve-plugin \ + xfce4-weather-plugin \ + xfce4-whiskermenu-plugin && \ + env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + libxv1 \ + mesa-utils \ + mesa-utils-extra && \ + sed -i 's%%%' /etc/xdg/xfce4/xfconf/xfce-perchannel-xml/xsettings.xml + +# disable xfwm4 compositing if X extension COMPOSITE is missing and no config file exists +RUN Configfile="~/.config/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml" && \ +echo "#! /bin/bash\n\ +xdpyinfo | grep -q -i COMPOSITE || {\n\ + echo 'x11docker/xfce: X extension COMPOSITE is missing.\n\ +Window manager compositing will not work.\n\ +If you run x11docker with option --nxagent,\n\ +you might want to add option --composite.' >&2\n\ + [ -e $Configfile ] || {\n\ + mkdir -p $(dirname $Configfile)\n\ + echo '\n\ +\n\ +\n\ + \n\ + \n\ + \n\ +\n\ +' > $Configfile\n\ + }\n\ +}\n\ +startxfce4\n\ +" > /usr/local/bin/start && \ +chmod +x /usr/local/bin/start + +COPY ./pkg/image/vnc/install.sh /tmp/install.sh +RUN chmod +x /tmp/install.sh +RUN /tmp/install.sh +ENV DISPLAY=:1 +ENTRYPOINT /usr/local/share/desktop-init.sh diff --git a/pkg/image/vnc/install.sh b/pkg/image/vnc/install.sh new file mode 100644 index 0000000..2732586 --- /dev/null +++ b/pkg/image/vnc/install.sh @@ -0,0 +1,434 @@ +#!/usr/bin/env bash + +NOVNC_VERSION="${NOVNCVERSION:-"1.2.0"}" # TODO: Add in a 'latest' auto-detect and swap name to 'version' +VNC_PASSWORD=${PASSWORD:-"vscode"} +if [ "$VNC_PASSWORD" = "noPassword" ]; then + unset VNC_PASSWORD +fi +NOVNC_PORT="${WEBPORT:-6080}" +VNC_PORT="${VNCPORT:-5901}" + +INSTALL_NOVNC="${INSTALL_NOVNC:-"true"}" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" + +WEBSOCKETIFY_VERSION=0.10.0 + +package_list=" + tigervnc-standalone-server \ + tigervnc-common \ + fluxbox \ + dbus-x11 \ + x11-utils \ + x11-xserver-utils \ + xdg-utils \ + fbautostart \ + at-spi2-core \ + xterm \ + eterm \ + nautilus\ + mousepad \ + seahorse \ + gnome-icon-theme \ + gnome-keyring \ + libx11-dev \ + libxkbfile-dev \ + libsecret-1-dev \ + libgbm-dev \ + libnotify4 \ + libnss3 \ + libxss1 \ + xfonts-base \ + xfonts-terminus \ + fonts-noto \ + fonts-wqy-microhei \ + fonts-droid-fallback \ + htop \ + ncdu \ + curl \ + ca-certificates\ + unzip \ + nano \ + locales" + +# Packages to attempt to install if essential tools are missing (ie: vncpasswd). +# This is useful, at least, for Ubuntu 22.04 (jammy) +package_list_additional=" + tigervnc-tools" + +set -e + +# Clean up +rm -rf /var/lib/apt/lists/* + +if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 +fi + +# Determine the appropriate non-root user +if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + USERNAME="" + POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do + if id -u ${CURRENT_USER} > /dev/null 2>&1; then + USERNAME=${CURRENT_USER} + break + fi + done + if [ "${USERNAME}" = "" ]; then + USERNAME=root + fi +elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then + USERNAME=root +fi +# Add default Fluxbox config files if none are already present +fluxbox_apps="$(cat \ +<< 'EOF' +[transient] (role=GtkFileChooserDialog) + [Dimensions] {70% 70%} + [Position] (CENTER) {0 0} +[end] +EOF +)" + +fluxbox_init="$(cat \ +<< 'EOF' +session.configVersion: 13 +session.menuFile: ~/.fluxbox/menu +session.keyFile: ~/.fluxbox/keys +session.styleFile: /usr/share/fluxbox/styles/qnx-photon +session.screen0.workspaces: 1 +session.screen0.workspacewarping: false +session.screen0.toolbar.widthPercent: 100 +session.screen0.strftimeFormat: %a %l:%M %p +session.screen0.toolbar.tools: RootMenu, clock, iconbar, systemtray +session.screen0.workspaceNames: One, +EOF +)" + +fluxbox_menu="$(cat \ +<< 'EOF' +[begin] ( Application Menu ) + [exec] (File Manager) { nautilus ~ } <> + [exec] (Text Editor) { mousepad } <> + [exec] (Terminal) { tilix -w ~ -e $(readlink -f /proc/$$/exe) -il } <> + [exec] (Web Browser) { x-www-browser --disable-dev-shm-usage } <> + [submenu] (System) {} + [exec] (Set Resolution) { tilix -t "Set Resolution" -e bash /usr/local/bin/set-resolution } <> + [exec] (Edit Application Menu) { mousepad ~/.fluxbox/menu } <> + [exec] (Passwords and Keys) { seahorse } <> + [exec] (Top Processes) { tilix -t "Top" -e htop } <> + [exec] (Disk Utilization) { tilix -t "Disk Utilization" -e ncdu / } <> + [exec] (Editres) {editres} <> + [exec] (Xfontsel) {xfontsel} <> + [exec] (Xkill) {xkill} <> + [exec] (Xrefresh) {xrefresh} <> + [end] + [config] (Configuration) + [workspaces] (Workspaces) +[end] +EOF +)" + +# Copy config files if the don't already exist +copy_fluxbox_config() { + local target_dir="$1" + mkdir -p "${target_dir}/.fluxbox" + touch "${target_dir}/.Xmodmap" + if [ ! -e "${target_dir}/.fluxbox/apps" ]; then + echo "${fluxbox_apps}" > "${target_dir}/.fluxbox/apps" + fi + if [ ! -e "${target_dir}/.fluxbox/init" ]; then + echo "${fluxbox_init}" > "${target_dir}/.fluxbox/init" + fi + if [ ! -e "${target_dir}/.fluxbox/menu" ]; then + echo "${fluxbox_menu}" > "${target_dir}/.fluxbox/menu" + fi +} + +apt_get_update() +{ + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi +} + +# Checks if packages are installed and installs them if not +check_packages() { + if ! dpkg -s "$@" > /dev/null 2>&1; then + apt_get_update + apt-get -y install --no-install-recommends "$@" + fi +} + +########################## +# Install starts here # +########################## + +# Ensure apt is in non-interactive to avoid prompts +export DEBIAN_FRONTEND=noninteractive + +apt_get_update + +# On older Ubuntu, Tilix is in a PPA. on Debian stretch its in backports. +if [[ -z $(apt-cache --names-only search ^tilix$) ]]; then + . /etc/os-release + if [ "${ID}" = "ubuntu" ]; then + check_packages apt-transport-https software-properties-common + add-apt-repository -y ppa:webupd8team/terminix + elif [ "${VERSION_CODENAME}" = "stretch" ]; then + echo "deb http://deb.debian.org/debian stretch-backports main" > /etc/apt/sources.list.d/stretch-backports.list + fi + apt-get update + if [[ -z $(apt-cache --names-only search ^tilix$) ]]; then + echo "(!) WARNING: Tilix not available on ${ID} ${VERSION_CODENAME} architecture $(uname -m). Skipping." + else + package_list="${package_list} tilix" + fi +else + package_list="${package_list} tilix" +fi + +# Install X11, fluxbox and VS Code dependencies +check_packages ${package_list} + +# if Ubuntu-24.04, noble(numbat) found, then will install libasound2-dev instead of libasound2. +# this change is temporary, https://packages.ubuntu.com/noble/libasound2 will switch to libasound2 once it is available for Ubuntu-24.04, noble(numbat) +. /etc/os-release +if [ "${ID}" = "ubuntu" ] && [ "${VERSION_CODENAME}" = "noble" ]; then + echo "Ubuntu 24.04, Noble(Numbat) detected. Installing libasound2-dev package..." + check_packages "libasound2-dev" +else + check_packages "libasound2" +fi + +# On newer versions of Ubuntu (22.04), +# we need an additional package that isn't provided in earlier versions +if ! type vncpasswd > /dev/null 2>&1; then + check_packages ${package_list_additional} +fi + +# Install Emoji font if available in distro - Available in Debian 10+, Ubuntu 18.04+ +if dpkg-query -W fonts-noto-color-emoji > /dev/null 2>&1 && ! dpkg -s fonts-noto-color-emoji > /dev/null 2>&1; then + apt-get -y install --no-install-recommends fonts-noto-color-emoji +fi + +# Check at least one locale exists +if ! grep -o -E '^\s*en_US.UTF-8\s+UTF-8' /etc/locale.gen > /dev/null; then + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen + locale-gen +fi + +# Install the Cascadia Code fonts - https://github.com/microsoft/cascadia-code +if [ ! -d "/usr/share/fonts/truetype/cascadia" ]; then + curl -sSL https://github.com/microsoft/cascadia-code/releases/download/v2008.25/CascadiaCode-2008.25.zip -o /tmp/cascadia-fonts.zip + unzip /tmp/cascadia-fonts.zip -d /tmp/cascadia-fonts + mkdir -p /usr/share/fonts/truetype/cascadia + mv /tmp/cascadia-fonts/ttf/* /usr/share/fonts/truetype/cascadia/ + rm -rf /tmp/cascadia-fonts.zip /tmp/cascadia-fonts +fi + +# Install noVNC +if [ "${INSTALL_NOVNC}" = "true" ] && [ ! -d "/usr/local/novnc" ]; then + mkdir -p /usr/local/novnc + curl -sSL https://github.com/novnc/noVNC/archive/v${NOVNC_VERSION}.zip -o /tmp/novnc-install.zip + unzip /tmp/novnc-install.zip -d /usr/local/novnc + cp /usr/local/novnc/noVNC-${NOVNC_VERSION}/vnc.html /usr/local/novnc/noVNC-${NOVNC_VERSION}/index.html + curl -sSL https://github.com/novnc/websockify/archive/v${WEBSOCKETIFY_VERSION}.zip -o /tmp/websockify-install.zip + unzip /tmp/websockify-install.zip -d /usr/local/novnc + ln -s /usr/local/novnc/websockify-${WEBSOCKETIFY_VERSION} /usr/local/novnc/noVNC-${NOVNC_VERSION}/utils/websockify + rm -f /tmp/websockify-install.zip /tmp/novnc-install.zip + + # Install noVNC dependencies and use them. + check_packages python3-minimal python3-numpy + sed -i -E 's/^python /python3 /' /usr/local/novnc/websockify-${WEBSOCKETIFY_VERSION}/run +fi + +# Set up folders for scripts and init files +mkdir -p /var/run/dbus /usr/local/etc/vscode-dev-containers/ + +# Script to change resolution of desktop +cat << EOF > /usr/local/bin/set-resolution +#!/bin/bash +RESOLUTION=\${1:-\${VNC_RESOLUTION:-1920x1080}} +DPI=\${2:-\${VNC_DPI:-96}} +IGNORE_ERROR=\${3:-"false"} +if [ -z "\$1" ]; then + echo -e "**Current Settings **\n" + xrandr + echo -n -e "\nEnter new resolution (WIDTHxHEIGHT, blank for \${RESOLUTION}, Ctrl+C to abort).\n> " + read NEW_RES + if [ "\${NEW_RES}" != "" ]; then + RESOLUTION=\${NEW_RES} + fi + if ! echo "\${RESOLUTION}" | grep -E '[0-9]+x[0-9]+' > /dev/null; then + echo -e "\nInvalid resolution format!\n" + exit 1 + fi + if [ -z "\$2" ]; then + echo -n -e "\nEnter new DPI (blank for \${DPI}, Ctrl+C to abort).\n> " + read NEW_DPI + if [ "\${NEW_DPI}" != "" ]; then + DPI=\${NEW_DPI} + fi + fi +fi + +xrandr --fb \${RESOLUTION} --dpi \${DPI} > /dev/null 2>&1 + +if [ \$? -ne 0 ] && [ "\${IGNORE_ERROR}" != "true" ]; then + echo -e "\nFAILED TO SET RESOLUTION!\n" + exit 1 +fi + +echo -e "\nSuccess!\n" +EOF + +# Container ENTRYPOINT script +cat << EOF > /usr/local/share/desktop-init.sh +#!/bin/bash + +user_name="${USERNAME}" +group_name="$(id -gn ${USERNAME})" +LOG=/tmp/container-init.log + +export DBUS_SESSION_BUS_ADDRESS="\${DBUS_SESSION_BUS_ADDRESS:-"autolaunch:"}" +export DISPLAY="\${DISPLAY:-:1}" +export VNC_RESOLUTION="\${VNC_RESOLUTION:-1440x768x16}" +export LANG="\${LANG:-"en_US.UTF-8"}" +export LANGUAGE="\${LANGUAGE:-"en_US.UTF-8"}" + +# Execute the command it not already running +startInBackgroundIfNotRunning() +{ + log "Starting \$1." + echo -e "\n** \$(date) **" | sudoIf tee -a /tmp/\$1.log > /dev/null + if ! pgrep -x \$1 > /dev/null; then + keepRunningInBackground "\$@" + while ! pgrep -x \$1 > /dev/null; do + sleep 1 + done + log "\$1 started." + else + echo "\$1 is already running." | sudoIf tee -a /tmp/\$1.log > /dev/null + log "\$1 is already running." + fi +} + +# Keep command running in background +keepRunningInBackground() +{ + (\$2 bash -c "while :; do echo [\\\$(date)] Process started.; \$3; echo [\\\$(date)] Process exited!; sleep 5; done 2>&1" | sudoIf tee -a /tmp/\$1.log > /dev/null & echo "\$!" | sudoIf tee /tmp/\$1.pid > /dev/null) +} + +# Use sudo to run as root when required +sudoIf() +{ + if [ "\$(id -u)" -ne 0 ]; then + sudo "\$@" + else + "\$@" + fi +} + +# Use sudo to run as non-root user if not already running +sudoUserIf() +{ + if [ "\$(id -u)" -eq 0 ] && [ "\${user_name}" != "root" ]; then + sudo -u \${user_name} "\$@" + else + "\$@" + fi +} + +# Log messages +log() +{ + echo -e "[\$(date)] \$@" | sudoIf tee -a \$LOG > /dev/null +} + +log "** SCRIPT START **" + +# Start dbus. +log 'Running "/etc/init.d/dbus start".' +if [ -f "/var/run/dbus/pid" ] && ! pgrep -x dbus-daemon > /dev/null; then + sudoIf rm -f /var/run/dbus/pid +fi +sudoIf /etc/init.d/dbus start 2>&1 | sudoIf tee -a /tmp/dbus-daemon-system.log > /dev/null +while ! pgrep -x dbus-daemon > /dev/null; do + sleep 1 +done + +# Startup tigervnc server and fluxbox +sudoIf rm -rf /tmp/.X11-unix /tmp/.X*-lock +mkdir -p /tmp/.X11-unix +sudoIf chmod 1777 /tmp/.X11-unix +sudoIf chown root:\${group_name} /tmp/.X11-unix +if [ "\$(echo "\${VNC_RESOLUTION}" | tr -cd 'x' | wc -c)" = "1" ]; then VNC_RESOLUTION=\${VNC_RESOLUTION}x16; fi +screen_geometry="\${VNC_RESOLUTION%*x*}" +screen_depth="\${VNC_RESOLUTION##*x}" + +# Check if VNC_PASSWORD is set and use the appropriate command +common_options="tigervncserver \${DISPLAY} -geometry \${screen_geometry} -depth \${screen_depth} -rfbport ${VNC_PORT} -dpi \${VNC_DPI:-96} -localhost -desktop fluxbox -fg" + +if [ -n "\${VNC_PASSWORD+x}" ]; then + startInBackgroundIfNotRunning "Xtigervnc" sudoUserIf "\${common_options} -passwd /usr/local/etc/vscode-dev-containers/vnc-passwd" +else + startInBackgroundIfNotRunning "Xtigervnc" sudoUserIf "\${common_options} -SecurityTypes None" +fi + +# Spin up noVNC if installed and not running. +if [ -d "/usr/local/novnc" ] && [ "\$(ps -ef | grep /usr/local/novnc/noVNC*/utils/launch.sh | grep -v grep)" = "" ]; then + keepRunningInBackground "noVNC" sudoIf "/usr/local/novnc/noVNC*/utils/launch.sh --listen ${NOVNC_PORT} --vnc localhost:${VNC_PORT}" + log "noVNC started." +else + log "noVNC is already running or not installed." +fi + +# Run whatever was passed in +if [ -n "$1" ]; then + log "Executing \"\$@\"." + exec "$@" +else + log "No command provided to execute." +fi +log "** SCRIPT EXIT **" +EOF + +if [ -n "${VNC_PASSWORD+x}" ]; then + echo "${VNC_PASSWORD}" | vncpasswd -f > /usr/local/etc/vscode-dev-containers/vnc-passwd +fi +chmod +x /usr/local/share/desktop-init.sh /usr/local/bin/set-resolution + +# Set up fluxbox config +copy_fluxbox_config "/root" +if [ "${USERNAME}" != "root" ]; then + copy_fluxbox_config "/home/${USERNAME}" + chown -R ${USERNAME} /home/${USERNAME}/.Xmodmap /home/${USERNAME}/.fluxbox +fi + +# Clean up +rm -rf /var/lib/apt/lists/* + +# Determine the message based on whether VNC_PASSWORD is set +if [ -n "${VNC_PASSWORD+x}" ]; then + PASSWORD_MESSAGE="In both cases, use the password \"${VNC_PASSWORD}\" when connecting" +else + PASSWORD_MESSAGE="In both cases, no password is required." +fi + +# Display the message +cat << EOF + + +You now have a working desktop! Connect to in one of the following ways: + +- Forward port ${NOVNC_PORT} and use a web browser to start the noVNC client (recommended) +- Forward port ${VNC_PORT} using VS Code client and connect using a VNC Viewer + +${PASSWORD_MESSAGE} + +(*) Done! + +EOF \ No newline at end of file