cranpose-services 0.1.163

Multiplatform system services for Cranpose (HTTP, URI, and OS integrations)
Documentation
use std::{
    fs::File,
    io::{Read, Write},
    path::{Path, PathBuf},
    sync::Arc,
    time::UNIX_EPOCH,
};

use super::{
    FolderEntry, FolderError, FolderReader, FolderWriter, WritableFolderStore,
    WritableFolderStoreRef,
};
use crate::content::DEFAULT_CHUNK_LEN;

const PROBE_NAME: &str = ".cranpose-write-probe";

pub(super) fn open(handle: &str) -> WritableFolderStoreRef {
    Arc::new(DesktopWritableFolder {
        dir: PathBuf::from(handle),
    })
}

struct DesktopWritableFolder {
    dir: PathBuf,
}

impl WritableFolderStore for DesktopWritableFolder {
    fn write(&self, name: &str, contents: &[u8]) -> Result<(), FolderError> {
        ensure_dir(&self.dir)?;
        let target = self.dir.join(name);
        let temp = self.dir.join(format!("{name}.tmp"));
        if std::fs::write(&temp, contents).is_ok() && std::fs::rename(&temp, &target).is_ok() {
            return Ok(());
        }
        let _ = std::fs::remove_file(&temp);
        std::fs::write(&target, contents).map_err(map_err)
    }

    fn read(&self, name: &str) -> Result<Vec<u8>, FolderError> {
        match std::fs::read(self.dir.join(name)) {
            Ok(bytes) => Ok(bytes),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                Err(FolderError::NotFound(name.to_string()))
            }
            Err(error) => Err(map_err(error)),
        }
    }

    fn list(&self) -> Result<Vec<FolderEntry>, FolderError> {
        let entries = match std::fs::read_dir(&self.dir) {
            Ok(entries) => entries,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
            Err(error) => return Err(map_err(error)),
        };
        let mut listing = Vec::new();
        for entry in entries.flatten() {
            if !entry.file_type().is_ok_and(|kind| kind.is_file()) {
                continue;
            }
            let Some(name) = entry.file_name().to_str().map(str::to_string) else {
                continue;
            };
            let stat = entry.metadata().map_err(map_err)?;
            listing.push(FolderEntry {
                name,
                len: stat.len(),
                modified_millis: stat
                    .modified()
                    .ok()
                    .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
                    .map(|since| since.as_millis() as u64),
            });
        }
        Ok(listing)
    }

    fn remove(&self, name: &str) -> Result<(), FolderError> {
        match std::fs::remove_file(self.dir.join(name)) {
            Ok(()) => Ok(()),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(error) => Err(map_err(error)),
        }
    }

    fn open_read(&self, name: &str) -> Result<Box<dyn FolderReader>, FolderError> {
        let path = self.dir.join(name);
        match File::open(&path) {
            Ok(file) => Ok(Box::new(FileFolderReader { file: Some(file) })),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                Err(FolderError::NotFound(name.to_string()))
            }
            Err(error) => Err(map_err(error)),
        }
    }

    fn open_write(&self, name: &str) -> Result<Box<dyn FolderWriter>, FolderError> {
        ensure_dir(&self.dir)?;
        let target = self.dir.join(name);
        let staging = self.dir.join(format!("{name}.tmp"));
        let file = File::create(&staging).map_err(map_err)?;
        Ok(Box::new(FileFolderWriter {
            file: Some(file),
            staging,
            target,
        }))
    }

    fn is_writable(&self) -> bool {
        if ensure_dir(&self.dir).is_err() {
            return false;
        }
        let probe = self.dir.join(PROBE_NAME);
        let ok = std::fs::write(&probe, b"ok").is_ok();
        let _ = std::fs::remove_file(&probe);
        ok
    }

    fn handle(&self) -> String {
        self.dir.to_string_lossy().into_owned()
    }
}

struct FileFolderReader {
    file: Option<File>,
}

impl FolderReader for FileFolderReader {
    fn read_chunk(&mut self) -> Result<Option<Vec<u8>>, FolderError> {
        let Some(file) = self.file.as_mut() else {
            return Ok(None);
        };
        let mut buffer = vec![0u8; DEFAULT_CHUNK_LEN];
        let mut filled = 0;
        while filled < buffer.len() {
            match file.read(&mut buffer[filled..]) {
                Ok(0) => break,
                Ok(read) => filled += read,
                Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
                Err(error) => return Err(map_err(error)),
            }
        }
        if filled == 0 {
            self.file = None;
            return Ok(None);
        }
        buffer.truncate(filled);
        Ok(Some(buffer))
    }
}

struct FileFolderWriter {
    file: Option<File>,
    staging: PathBuf,
    target: PathBuf,
}

impl FolderWriter for FileFolderWriter {
    fn write_chunk(&mut self, bytes: &[u8]) -> Result<(), FolderError> {
        let file = self
            .file
            .as_mut()
            .ok_or_else(|| FolderError::Io("folder writer is already finished".into()))?;
        file.write_all(bytes).map_err(map_err)
    }

    fn finish(mut self: Box<Self>) -> Result<(), FolderError> {
        let Some(mut file) = self.file.take() else {
            return Ok(());
        };
        file.flush().map_err(map_err)?;
        file.sync_all().map_err(map_err)?;
        drop(file);
        std::fs::rename(&self.staging, &self.target).map_err(map_err)
    }
}

impl Drop for FileFolderWriter {
    fn drop(&mut self) {
        if self.file.is_some() {
            let _ = std::fs::remove_file(&self.staging);
        }
    }
}

fn ensure_dir(dir: &Path) -> Result<(), FolderError> {
    std::fs::create_dir_all(dir).map_err(map_err)
}

fn map_err(error: std::io::Error) -> FolderError {
    if error.kind() == std::io::ErrorKind::PermissionDenied {
        return FolderError::ReadOnly;
    }
    #[cfg(unix)]
    if matches!(error.raw_os_error(), Some(13 | 30)) {
        return FolderError::ReadOnly;
    }
    FolderError::Io(error.to_string())
}

#[cfg(test)]
#[path = "tests/desktop_tests.rs"]
mod tests;