Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions products/maka/desktop-shell/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ uuid.workspace = true
[build-dependencies]
embed-resource = "3.0"

[target.'cfg(unix)'.dependencies]
libc.workspace = true

[target.'cfg(target_os = "linux")'.dependencies]
nsh-platform-linux.workspace = true

Expand Down
2 changes: 1 addition & 1 deletion products/maka/desktop-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Start it with the launcher:

`--root` selects the business Host Root and defaults to `~/.maka/application/host`. The launcher initializes a missing Root with the standard read-only business Host or reopens its saved deployment, verifies discovery against the connected Host endpoint, source and epoch, and supplies `MAKA_HOST_ROOT`, `MAKA_HOST_ENDPOINT` and `MAKA_HOST_EPOCH` to the shell, with its own path in `MAKA_LAUNCHER`. The shell admits itself to that Host as the terminal shell does and keeps its client data under the Root.

Opened directly, from Finder, the Dock or a file manager, the shell has none of these variables. It then runs `maka desktop` from the directory it was staged in, beside `Maka.app` on macOS or beside the shell elsewhere, and exits; that launcher opens the window for the default Root.
Opened directly, from Finder, the Dock or a file manager, the shell has none of these variables. On Unix without a terminal environment (unless `MAKA_SKIP_SHELL_ENV=1` is set), it first captures `PATH` from the user's login shell so user-installed CLIs are visible to the Host. It then runs `maka desktop` from the directory it was staged in, beside `Maka.app` on macOS or beside the shell elsewhere, and exits; that launcher opens the window for the default Root.

Closing the window hides it and keeps its one UI engine and Host client; the tray's Open brings it back. On Linux without a panel that hosts the tray, closing quits instead, and a panel that leaves brings a hidden window back. Quit from the tray, the application menu or the quit key closes the desktop and launcher sessions. The last client to quit shuts down an ephemeral Host and settles the work it owns. A shared or managed Host continues serving its clients.

Expand Down
2 changes: 1 addition & 1 deletion products/maka/desktop-shell/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ just build

`--root` 可选,指定业务 Host Root,默认使用 `~/.maka/application/host`。启动器把缺失的 Root 初始化为标准只读业务 Host,或重开其已保存的部署;随后对照已连接 Host 的端点、来源和 epoch 校验发现信息,并把 `MAKA_HOST_ROOT`、`MAKA_HOST_ENDPOINT` 和 `MAKA_HOST_EPOCH` 传给 shell,同时用 `MAKA_LAUNCHER` 传入启动器自身的路径。shell 以与终端 shell 相同的方式接入该 Host,并把客户端数据保存在该 Root 下。

从访达、程序坞或文件管理器直接打开时,shell 拿不到这些变量。它会运行所在目录里的 `maka desktop`(macOS 上是 `Maka.app` 旁边,其他平台是 shell 旁边),然后退出;由这个启动器为默认 Root 打开窗口。
从访达、程序坞或文件管理器直接打开时,shell 拿不到这些变量。Unix 上没有终端环境且未设置 `MAKA_SKIP_SHELL_ENV=1` 时,它会先从用户的登录 shell 读取 `PATH`,使 Host 能找到用户安装的 CLI;随后运行所在目录里的 `maka desktop`(macOS 上是 `Maka.app` 旁边,其他平台是 shell 旁边),然后退出,由这个启动器为默认 Root 打开窗口。

关闭窗口只会隐藏窗口,并保留唯一的 UI 引擎和 Host 客户端;托盘的“打开”可以重新显示窗口。Linux 上如果没有能显示托盘的面板,关闭窗口会直接退出;面板离开时,已隐藏的窗口会重新显示。通过托盘、应用菜单或退出快捷键退出时,桌面和启动器会关闭各自的会话。最后一个客户端退出时,临时 Host 会停止并收尾它持有的工作。共享或受管的 Host 继续为其客户端服务。

Expand Down
331 changes: 331 additions & 0 deletions products/maka/desktop-shell/src/login_path.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

use std::{
ffi::{CStr, OsStr, OsString},
io::{self, Read},
os::unix::{
ffi::{OsStrExt, OsStringExt},
fs::PermissionsExt,
process::CommandExt,
},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::mpsc::{self, RecvTimeoutError},
time::{Duration, Instant},
};

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_CAPTURE_BYTES: usize = 64 * 1024;

pub fn resolve() -> Option<OsString> {
if std::env::var_os("MAKA_SKIP_SHELL_ENV").as_deref() == Some(OsStr::new("1"))
|| non_empty("TERM")
|| non_empty("COLORTERM")
{
return None;
}
let shell = select_shell(
std::env::var_os("SHELL").as_deref(),
account_shell().as_deref(),
);
match capture(&shell, DEFAULT_TIMEOUT) {
Ok(path) => Some(path),
Err(error) => {
eprintln!("maka-desktop-shell: login shell PATH: {error}");
None
}
}
}

fn non_empty(name: &str) -> bool {
std::env::var_os(name).is_some_and(|value| !value.is_empty())
}

fn executable(path: &Path) -> bool {
path.is_absolute()
&& std::fs::metadata(path)
.is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
}

fn select_shell(inherited: Option<&OsStr>, account: Option<&Path>) -> PathBuf {
if let Some(raw) = inherited {
let trimmed = Path::new(OsStr::from_bytes(raw.as_bytes().trim_ascii()));
if executable(trimmed) {
return trimmed.to_owned();
}
}
if let Some(account) = account.filter(|path| executable(path)) {
return account.to_owned();
}
PathBuf::from(if cfg!(target_os = "macos") {
"/bin/zsh"
} else {
"/bin/sh"
})
}

fn account_shell() -> Option<PathBuf> {
let mut buffer = vec![0u8; 65_536];
let mut entry = std::mem::MaybeUninit::<libc::passwd>::uninit();
let mut result = std::ptr::null_mut();
// SAFETY: out pointers and scratch buffer outlive the call; pw_shell is copied before return.
unsafe {
if libc::getpwuid_r(
libc::geteuid(),
entry.as_mut_ptr(),
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut result,
) != 0
|| result.is_null()
{
return None;
}
let entry = entry.assume_init();
if entry.pw_shell.is_null() {
return None;
}
let bytes = CStr::from_ptr(entry.pw_shell).to_bytes().trim_ascii();
(!bytes.is_empty()).then(|| PathBuf::from(OsStr::from_bytes(bytes)))
}
}

enum Chunk {
Bytes(Vec<u8>),
Overflow,
}

struct Probe {
child: Child,
pgid: Option<libc::pid_t>,
}

impl Probe {
fn spawn(shell: &Path, mark: &str) -> Result<(Self, std::process::ChildStdout), String> {
let name = shell
.file_name()
.and_then(OsStr::to_str)
.unwrap_or_default();
let prefix = if name == "nu" { "^" } else { "" };
// Spawn /bin/sh so shells where $PATH is a list (fish, nu, csh) export a colon-delimited PATH.
let script =
format!("{prefix}/bin/sh -c 'printf \"%s%s%s\" \"$1\" \"$PATH\" \"$1\"' sh {mark}");
let mut command = Command::new(shell);
if matches!(name, "csh" | "tcsh") {
command.arg0(format!("-{name}")).arg("-ic");
} else {
command.args(["-i", "-l", "-c"]);
}
command
.arg(script)
.env("DISABLE_AUTO_UPDATE", "true")
.env("DISABLE_UPDATE_PROMPT", "true")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
// SAFETY: setsid is async-signal-safe and isolates the interactive shell in its own session/pgrp.
unsafe {
command.pre_exec(|| {
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let mut child = command
.spawn()
.map_err(|error| format!("{}: {error}", shell.display()))?;
let stdout = child.stdout.take().expect("piped stdout");
let pgid = libc::pid_t::try_from(child.id()).ok().filter(|&id| id > 0);
Ok((Self { child, pgid }, stdout))
}

fn kill_group(&mut self) {
if let Some(pgid) = self.pgid.take() {
// SAFETY: setsid made child the group leader; -pgid signals only that dedicated group.
unsafe {
libc::kill(-pgid, libc::SIGKILL);
}
}
}
}

impl Drop for Probe {
fn drop(&mut self) {
self.kill_group();
let _ = self.child.wait();
}
}

fn capture(shell: &Path, timeout: Duration) -> Result<OsString, String> {
let mark = format!("__MAKA_PATH_{}__", uuid::Uuid::new_v4().simple());
let (mut probe, mut stdout) = Probe::spawn(shell, &mark)?;
let (tx, rx) = mpsc::sync_channel(16);
std::thread::spawn(move || {
let mut total = 0usize;
let mut chunk = [0u8; 4096];
while let Ok(count) = stdout.read(&mut chunk) {
if count == 0 {
break;
}
total += count;
if total > MAX_CAPTURE_BYTES {
let _ = tx.send(Chunk::Overflow);
break;
}
if tx.send(Chunk::Bytes(chunk[..count].to_vec())).is_err() {
break;
}
}
});

let deadline = Instant::now() + timeout;
let mut output = Vec::new();
let mut eof = false;
loop {
if let Some(parsed) = extract(&output, mark.as_bytes()) {
return parsed.map_err(str::to_owned);
}
if eof
&& let Some(status) = probe
.child
.try_wait()
.map_err(|error| format!("{}: {error}", shell.display()))?
{
return if status.success() {
Err(format!("missing PATH markers from {}", shell.display()))
} else {
Err(format!("{} exited with {status}", shell.display()))
};
}
let now = Instant::now();
if now >= deadline {
return Err(format!("timed out waiting for {}", shell.display()));
}
if eof {
std::thread::sleep(Duration::from_millis(5).min(deadline - now));
continue;
}
match rx.recv_timeout((deadline - now).min(Duration::from_millis(50))) {
Ok(Chunk::Bytes(bytes)) => output.extend_from_slice(&bytes),
Ok(Chunk::Overflow) => {
return Err(format!(
"{} output exceeded {MAX_CAPTURE_BYTES} bytes",
shell.display()
));
}
Err(RecvTimeoutError::Disconnected) => eof = true,
Err(RecvTimeoutError::Timeout) => {}
}
}
}

fn extract(output: &[u8], mark: &[u8]) -> Option<Result<OsString, &'static str>> {
let start = output
.windows(mark.len())
.position(|window| window == mark)?
+ mark.len();
let rest = &output[start..];
let end = rest.windows(mark.len()).position(|window| window == mark)?;
let bytes = &rest[..end];
if bytes.is_empty() || bytes.contains(&0) {
return Some(Err("login shell returned an empty or invalid PATH"));
}
Some(Ok(OsString::from_vec(bytes.to_vec())))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn shell_selection_falls_back_through_executable_candidates() {
let dir = std::env::temp_dir().join(format!("maka-login-path-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let inherited = dir.join("inherited-sh");
let account = dir.join("account-sh");
std::fs::write(&inherited, "#!/bin/sh\n").unwrap();
std::fs::write(&account, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&inherited, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::set_permissions(&account, std::fs::Permissions::from_mode(0o700)).unwrap();

let padded = OsString::from(format!(" {} \n", inherited.display()));
assert_eq!(select_shell(Some(&padded), Some(&account)), inherited);

std::fs::set_permissions(&inherited, std::fs::Permissions::from_mode(0o600)).unwrap();
assert_eq!(select_shell(Some(&padded), Some(&account)), account);
assert_eq!(
select_shell(Some(OsStr::new("relative/sh")), Some(&account)),
account
);

let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn timeout_kills_hung_shell_and_its_process_group() {
let dir = std::env::temp_dir().join(format!("maka-login-timeout-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let shell = dir.join("hung-shell");
let pid_file = dir.join("child.pid");
std::fs::write(
&shell,
format!(
"#!/bin/sh\n[ \"$1\" = warmup ] && exit 0\n( trap '' HUP TERM; sleep 60 ) </dev/null >/dev/null 2>&1 &\nprintf '%s' \"$!\" > '{}'\nwait\n",
pid_file.display()
),
)
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(
Command::new(&shell)
.arg("warmup")
.status()
.unwrap()
.success()
);

let error = capture(&shell, Duration::from_millis(500)).unwrap_err();
assert!(error.contains("timed out"), "{error}");

let pid: libc::pid_t = std::fs::read_to_string(&pid_file)
.unwrap()
.trim()
.parse()
.unwrap();
let started = Instant::now();
while started.elapsed() < Duration::from_secs(5) {
// SAFETY: signal 0 only probes process existence.
if unsafe { libc::kill(pid, 0) } != 0
&& io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
{
let _ = std::fs::remove_dir_all(&dir);
return;
}
std::thread::sleep(Duration::from_millis(20));
}
// SAFETY: clean up leaked test child before failing.
unsafe {
libc::kill(pid, libc::SIGKILL);
}
let _ = std::fs::remove_dir_all(&dir);
panic!("descendant {pid} survived timeout cleanup");
}
}
6 changes: 6 additions & 0 deletions products/maka/desktop-shell/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ use direct2d as backend;
mod host;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(unix)]
mod login_path;
#[cfg(target_os = "linux")]
mod placement;
#[cfg(any(target_os = "linux", target_os = "macos", windows))]
Expand Down Expand Up @@ -195,6 +197,10 @@ fn launch() -> Result<(), String> {
.join(if cfg!(windows) { "maka.exe" } else { "maka" });
let mut command = std::process::Command::new(&launcher);
command.arg("desktop");
#[cfg(unix)]
if let Some(path) = login_path::resolve() {
command.env("PATH", path);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
Expand Down
Loading
Loading