Skip to content

Commit a71bf72

Browse files
committed
feat: add isolated portable profile smoke
1 parent 278656e commit a71bf72

4 files changed

Lines changed: 77 additions & 17 deletions

File tree

docs/windows-distribution.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ Before distributing a portable ZIP, run its release executable from a fresh temp
2121
.\scripts\smoke-portable-startup.ps1 -ArchivePath "dist\FlashShot-0.1.0-windows-x86_64.zip"
2222
```
2323

24-
This is an artifact-startup preflight, not a substitute for manually testing on a clean Windows user profile.
24+
The smoke script sets `FLASH_SHOT_PROFILE_DIR` to a disposable directory and verifies that config,
25+
data, cache, and history are initialized there. This is an isolated profile preflight, not a
26+
substitute for manually testing on a clean Windows user account.
2527

2628
The package intentionally does not include FFmpeg. Recording users must install a compatible FFmpeg build or set `FLASH_SHOT_FFMPEG` to its executable path. This keeps the application license boundary and FFmpeg distribution choice explicit.
2729

scripts/smoke-portable-startup.ps1

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ if (Get-Process -Name "flash-shot" -ErrorAction SilentlyContinue) {
2121

2222
$packageRoot = [IO.Path]::GetFileNameWithoutExtension($archive)
2323
$staging = Join-Path ([IO.Path]::GetTempPath()) ("flash-shot-portable-smoke-" + [guid]::NewGuid())
24+
$profileDirectory = Join-Path $staging "profile"
25+
$previousProfileDirectory = [Environment]::GetEnvironmentVariable("FLASH_SHOT_PROFILE_DIR", "Process")
2426
$process = $null
2527
try {
2628
Expand-Archive -LiteralPath $archive -DestinationPath $staging
@@ -29,14 +31,21 @@ try {
2931
throw "Portable archive has no flash-shot executable at the expected path."
3032
}
3133

34+
New-Item -ItemType Directory -Force -Path $profileDirectory | Out-Null
35+
[Environment]::SetEnvironmentVariable("FLASH_SHOT_PROFILE_DIR", $profileDirectory, "Process")
3236
$process = Start-Process -FilePath $executable -WorkingDirectory (Split-Path -Parent $executable) -PassThru
3337
Start-Sleep -Seconds $StartupSeconds
3438
$process.Refresh()
3539
if ($process.HasExited) {
3640
throw "Portable Flash Shot exited during startup with exit code $($process.ExitCode)."
3741
}
42+
foreach ($requiredDirectory in @("config", "data", "cache", "history")) {
43+
if (-not (Test-Path -LiteralPath (Join-Path $profileDirectory $requiredDirectory) -PathType Container)) {
44+
throw "Portable Flash Shot did not initialize isolated profile directory '$requiredDirectory'."
45+
}
46+
}
3847

39-
Write-Host "Portable Flash Shot stayed running for $StartupSeconds seconds."
48+
Write-Host "Portable Flash Shot stayed running for $StartupSeconds seconds with an isolated profile."
4049
}
4150
finally {
4251
if ($null -ne $process) {
@@ -46,6 +55,7 @@ finally {
4655
$process.WaitForExit()
4756
}
4857
}
58+
[Environment]::SetEnvironmentVariable("FLASH_SHOT_PROFILE_DIR", $previousProfileDirectory, "Process")
4959
if (Test-Path -LiteralPath $staging) {
5060
Remove-Item -LiteralPath $staging -Recurse -Force
5161
}

src/diagnostics.rs

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use directories::ProjectDirs;
1313
use log::{LevelFilter, Log, Metadata, Record};
1414

1515
const LOG_FILE_NAME: &str = "flash-shot.jsonl";
16+
const PROFILE_DIR_ENV: &str = "FLASH_SHOT_PROFILE_DIR";
1617
type PanicHook = Box<dyn Fn(&panic::PanicHookInfo<'_>) + Sync + Send + 'static>;
1718

1819
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -25,23 +26,41 @@ pub struct AppPaths {
2526

2627
impl AppPaths {
2728
pub fn discover() -> io::Result<Self> {
28-
let project = ProjectDirs::from("com", "BruceBlink", "Flash Shot").ok_or_else(|| {
29-
io::Error::new(
30-
io::ErrorKind::NotFound,
31-
"application directories unavailable",
32-
)
33-
})?;
34-
35-
let paths = Self {
36-
config_dir: project.config_dir().to_path_buf(),
37-
data_dir: project.data_dir().to_path_buf(),
38-
cache_dir: project.cache_dir().to_path_buf(),
39-
log_dir: project.data_dir().join("logs"),
40-
};
29+
let paths =
30+
if let Some(root) = std::env::var_os(PROFILE_DIR_ENV).filter(|root| !root.is_empty()) {
31+
Self::from_profile_root(PathBuf::from(root))
32+
} else {
33+
let project =
34+
ProjectDirs::from("com", "BruceBlink", "Flash Shot").ok_or_else(|| {
35+
io::Error::new(
36+
io::ErrorKind::NotFound,
37+
"application directories unavailable",
38+
)
39+
})?;
40+
Self {
41+
config_dir: project.config_dir().to_path_buf(),
42+
data_dir: project.data_dir().to_path_buf(),
43+
cache_dir: project.cache_dir().to_path_buf(),
44+
log_dir: project.data_dir().join("logs"),
45+
}
46+
};
4147
paths.create()?;
4248
Ok(paths)
4349
}
4450

51+
/// Maps an explicit test profile root to every writable application directory.
52+
///
53+
/// Keeping the root mapping in one place prevents a clean-profile run from accidentally
54+
/// writing settings, metrics, logs, or cache entries into the user's normal profile.
55+
fn from_profile_root(root: PathBuf) -> Self {
56+
Self {
57+
config_dir: root.join("config"),
58+
data_dir: root.join("data"),
59+
cache_dir: root.join("cache"),
60+
log_dir: root.join("data").join("logs"),
61+
}
62+
}
63+
4564
fn create(&self) -> io::Result<()> {
4665
for path in [
4766
&self.config_dir,
@@ -157,7 +176,7 @@ fn unix_timestamp_ms() -> u128 {
157176

158177
#[cfg(test)]
159178
mod tests {
160-
use super::{JsonLogger, LOG_FILE_NAME, Log, Metadata, Record};
179+
use super::{AppPaths, JsonLogger, LOG_FILE_NAME, Log, Metadata, Record};
161180
use log::{Level, LevelFilter};
162181
use std::{fs::OpenOptions, sync::Mutex};
163182

@@ -213,4 +232,15 @@ mod tests {
213232

214233
assert!(!logger.enabled(&metadata));
215234
}
235+
236+
#[test]
237+
fn profile_root_paths_are_kept_inside_the_explicit_root() {
238+
let root = std::path::PathBuf::from("target/profile-fixture");
239+
let paths = AppPaths::from_profile_root(root.clone());
240+
241+
assert_eq!(paths.config_dir, root.join("config"));
242+
assert_eq!(paths.data_dir, root.join("data"));
243+
assert_eq!(paths.cache_dir, root.join("cache"));
244+
assert_eq!(paths.log_dir, root.join("data/logs"));
245+
}
216246
}

src/history.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ use std::{
99

1010
const INDEX_FILE: &str = "history.json";
1111
const DEFAULT_LIMIT: usize = 30;
12+
const PROFILE_DIR_ENV: &str = "FLASH_SHOT_PROFILE_DIR";
1213

1314
/// Returns the only directory whose screenshot files this feature manages.
1415
pub fn managed_history_directory() -> io::Result<PathBuf> {
16+
if let Some(root) = std::env::var_os(PROFILE_DIR_ENV).filter(|root| !root.is_empty()) {
17+
return create_managed_history_directory(PathBuf::from(root).join("history"));
18+
}
1519
let user_dirs = directories::UserDirs::new().ok_or_else(|| {
1620
io::Error::new(
1721
io::ErrorKind::NotFound,
@@ -24,7 +28,11 @@ pub fn managed_history_directory() -> io::Result<PathBuf> {
2428
"user picture directory is unavailable",
2529
)
2630
})?;
27-
let directory = pictures.join("Flash Shot");
31+
create_managed_history_directory(pictures.join("Flash Shot"))
32+
}
33+
34+
/// Creates the history root selected by the current profile without exposing files outside it.
35+
fn create_managed_history_directory(directory: PathBuf) -> io::Result<PathBuf> {
2836
fs::create_dir_all(&directory)?;
2937
Ok(directory)
3038
}
@@ -358,6 +366,16 @@ mod tests {
358366
))
359367
}
360368

369+
#[test]
370+
fn isolated_profile_history_uses_its_private_root() {
371+
let root = directory("profile");
372+
let history = super::create_managed_history_directory(root.join("history")).unwrap();
373+
374+
assert_eq!(history, root.join("history"));
375+
assert!(history.is_dir());
376+
fs::remove_dir_all(root).unwrap();
377+
}
378+
361379
#[test]
362380
fn records_existing_managed_files_and_restores_them_on_restart() {
363381
let root = directory("reload");

0 commit comments

Comments
 (0)