Skip to content

Commit ee01ae7

Browse files
committed
Add license section to README, new SVG icons, and improve profile handling
- Added a License section to the README file specifying MIT License. - Introduced new SVG icons for external links and chevron in the config directory. - Enhanced profile loading and saving logic in the configuration module to handle errors more gracefully. - Implemented a function to save the current profile only if it is missing. - Updated various references from "key" to "button" in the UI and error messages for consistency. - Improved the handling of blank profiles and added tests for profile loading and saving. - Refactored configuration parsing to validate keys and handle empty lists appropriately. - Enhanced the UI to include an About dialog with links to the project's website and issue tracker.
1 parent 8032ba2 commit ee01ae7

25 files changed

+681
-74
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
[package]
22
name = "streamrs"
3-
version = "0.5.2"
3+
version = "0.5.3"
44
edition = "2024"
5+
license = "MIT"
56

67
[dependencies]
78
adw = { package = "libadwaita", version = "0.9.1" }

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Eriks Remess
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Readme.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,7 @@ Developer-focused setup, source builds, packaging, maintainer notes, and contrib
154154
## Credits
155155

156156
- Icon pack source: https://marketplace.elgato.com/product/hexaza-3d4ed1dc-bf33-4f30-9ecd-201769f10c0d
157+
158+
## License
159+
160+
MIT. See [LICENSE](LICENSE).

config/about-chevron-right.svg

Lines changed: 3 additions & 0 deletions
Loading

config/about-external-link.svg

Lines changed: 5 additions & 0 deletions
Loading

src/cli/preview.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,14 @@ fn first_readable_dir(candidates: &[PathBuf]) -> Option<PathBuf> {
3333
}
3434

3535
fn resolve_default_profile_name() -> String {
36-
load_current_profile()
37-
.ok()
38-
.flatten()
39-
.unwrap_or_else(|| DEFAULT_PROFILE.to_string())
36+
match load_current_profile() {
37+
Ok(Some(profile)) => profile,
38+
Ok(None) => DEFAULT_PROFILE.to_string(),
39+
Err(err) => {
40+
eprintln!("{err}");
41+
DEFAULT_PROFILE.to_string()
42+
}
43+
}
4044
}
4145

4246
fn default_config_path(profile: &str) -> PathBuf {

src/config/current_profile.rs

Lines changed: 133 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use crate::paths::{current_profile_path, default_config_path_for_profile};
22
use std::fs;
3+
use std::io::Write;
4+
use std::time::{SystemTime, UNIX_EPOCH};
35

46
pub const DEFAULT_PROFILE: &str = "default";
57
pub const BLANK_PROFILE: &str = "blank";
@@ -8,6 +10,41 @@ fn is_discoverable_profile_name(profile: &str) -> bool {
810
profile != BLANK_PROFILE && normalize_profile_name(profile).is_some()
911
}
1012

13+
fn normalize_current_profile_candidate(raw: &str) -> Option<String> {
14+
let mut candidate = raw.trim();
15+
if let Some((key, value)) = candidate.split_once('=')
16+
&& key.trim().eq_ignore_ascii_case("profile")
17+
{
18+
candidate = value.trim();
19+
}
20+
candidate = candidate.trim_matches(|ch| ch == '"' || ch == '\'');
21+
normalize_profile_name(candidate)
22+
}
23+
24+
fn parse_current_profile_contents(raw: &str) -> Result<Option<String>, String> {
25+
let mut first_invalid = None::<String>;
26+
for line in raw.lines() {
27+
let mut candidate = line.trim();
28+
if candidate.is_empty() || candidate.starts_with('#') {
29+
continue;
30+
}
31+
candidate = candidate.trim_start_matches('\u{feff}').trim_start();
32+
if candidate.is_empty() || candidate.starts_with('#') {
33+
continue;
34+
}
35+
if let Some(profile) = normalize_current_profile_candidate(candidate) {
36+
return Ok(Some(profile));
37+
}
38+
if first_invalid.is_none() {
39+
first_invalid = Some(candidate.to_string());
40+
}
41+
}
42+
if let Some(invalid) = first_invalid {
43+
return Err(invalid);
44+
}
45+
Ok(None)
46+
}
47+
1148
pub fn normalize_profile_name(raw: &str) -> Option<String> {
1249
let profile = raw.trim();
1350
if profile.is_empty() {
@@ -102,17 +139,14 @@ pub fn load_current_profile() -> Result<Option<String>, String> {
102139
if !path.is_file() {
103140
return Ok(None);
104141
}
105-
let raw = fs::read_to_string(&path)
142+
let bytes = fs::read(&path)
106143
.map_err(|err| format!("Failed to read current profile '{}': {err}", path.display()))?;
107-
let trimmed = raw.trim();
108-
if trimmed.is_empty() {
109-
return Ok(None);
110-
}
111-
normalize_profile_name(trimmed).map(Some).ok_or_else(|| {
144+
let raw = String::from_utf8_lossy(&bytes);
145+
parse_current_profile_contents(&raw).map_err(|invalid| {
112146
format!(
113147
"Current profile file '{}' contains invalid profile '{}'",
114148
path.display(),
115-
trimmed
149+
invalid
116150
)
117151
})
118152
}
@@ -129,14 +163,56 @@ pub fn save_current_profile(profile: &str) -> Result<(), String> {
129163
)
130164
})?;
131165
}
132-
fs::write(&path, format!("{profile}\n")).map_err(|err| {
166+
let file_name = path
167+
.file_name()
168+
.and_then(|name| name.to_str())
169+
.unwrap_or("current_profile");
170+
let unique = SystemTime::now()
171+
.duration_since(UNIX_EPOCH)
172+
.unwrap_or_default()
173+
.as_nanos();
174+
let tmp_path = path.with_file_name(format!(".{file_name}.tmp-{}-{unique}", std::process::id()));
175+
let data = format!("{profile}\n");
176+
177+
let mut file = fs::File::create(&tmp_path).map_err(|err| {
178+
format!(
179+
"Failed to create temporary current profile '{}': {err}",
180+
tmp_path.display()
181+
)
182+
})?;
183+
file.write_all(data.as_bytes()).map_err(|err| {
184+
format!(
185+
"Failed to write temporary current profile '{}': {err}",
186+
tmp_path.display()
187+
)
188+
})?;
189+
file.sync_all().map_err(|err| {
190+
format!(
191+
"Failed to sync temporary current profile '{}': {err}",
192+
tmp_path.display()
193+
)
194+
})?;
195+
196+
fs::rename(&tmp_path, &path).map_err(|err| {
197+
let _ = fs::remove_file(&tmp_path);
133198
format!(
134199
"Failed to write current profile '{}': {err}",
135200
path.display()
136201
)
137202
})
138203
}
139204

205+
pub fn save_current_profile_if_missing(profile: &str) -> Result<bool, String> {
206+
match load_current_profile() {
207+
Ok(Some(_)) => Ok(false),
208+
Ok(None) => {
209+
save_current_profile(profile)?;
210+
Ok(true)
211+
}
212+
Err(err) => Err(err),
213+
}
214+
}
215+
140216
#[cfg(test)]
141217
mod tests {
142218
use super::*;
@@ -233,6 +309,36 @@ mod tests {
233309
});
234310
}
235311

312+
#[test]
313+
fn load_current_profile_accepts_profile_assignment_line() {
314+
with_temp_xdg_config_home("profile-assignment", || {
315+
let path = current_profile_path();
316+
if let Some(parent) = path.parent() {
317+
fs::create_dir_all(parent).expect("current profile dir should be created");
318+
}
319+
fs::write(&path, "profile = \"test_profile\"\n")
320+
.expect("assignment fixture should be written");
321+
322+
let loaded = load_current_profile().expect("profile assignment should load");
323+
assert_eq!(loaded.as_deref(), Some("test_profile"));
324+
});
325+
}
326+
327+
#[test]
328+
fn load_current_profile_ignores_comments_and_blank_lines() {
329+
with_temp_xdg_config_home("comments-and-blanks", || {
330+
let path = current_profile_path();
331+
if let Some(parent) = path.parent() {
332+
fs::create_dir_all(parent).expect("current profile dir should be created");
333+
}
334+
fs::write(&path, "\n# selected profile\n\nwork_setup\n")
335+
.expect("comment fixture should be written");
336+
337+
let loaded = load_current_profile().expect("commented profile should load");
338+
assert_eq!(loaded.as_deref(), Some("work_setup"));
339+
});
340+
}
341+
236342
#[test]
237343
fn profile_slug_from_input_accepts_spaces() {
238344
assert_eq!(
@@ -251,4 +357,23 @@ mod tests {
251357
assert_eq!(profile_display_name("work_setup"), "Work Setup");
252358
assert_eq!(profile_display_name("work"), "Work");
253359
}
360+
361+
#[test]
362+
fn save_current_profile_if_missing_writes_once() {
363+
with_temp_xdg_config_home("save-if-missing", || {
364+
let wrote = save_current_profile_if_missing("test_profile")
365+
.expect("missing current profile should be created");
366+
assert!(wrote, "first write should persist profile");
367+
368+
let wrote_again = save_current_profile_if_missing("default")
369+
.expect("existing current profile should not be overwritten");
370+
assert!(
371+
!wrote_again,
372+
"existing current profile should remain unchanged"
373+
);
374+
375+
let loaded = load_current_profile().expect("current profile should load");
376+
assert_eq!(loaded.as_deref(), Some("test_profile"));
377+
});
378+
}
254379
}

src/config/streamrs.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ pub(crate) fn read_config_file(path: &Path) -> Result<String, String> {
99
streamrs::config::toml::read_to_string(path)
1010
}
1111

12-
pub(crate) fn parse_config(path: &Path, raw: &str) -> Result<Config, String> {
13-
let config: Config = streamrs::config::toml::parse_from_str(path, raw)?;
14-
12+
fn validate_config(path: &Path, config: &Config) -> Result<(), String> {
1513
if config.keys.is_empty() {
1614
return Err(format!("Config '{}' has no keys", path.display()));
1715
}
@@ -24,7 +22,19 @@ pub(crate) fn parse_config(path: &Path, raw: &str) -> Result<Config, String> {
2422
KEY_COUNT
2523
));
2624
}
25+
Ok(())
26+
}
2727

28+
pub(crate) fn load_config(path: &Path, profile: &str) -> Result<Config, String> {
29+
let config = streamrs::config::streamrs_profile::load_config_for_profile(path, profile)?;
30+
validate_config(path, &config)?;
31+
Ok(config)
32+
}
33+
34+
#[cfg(test)]
35+
pub(crate) fn parse_config(path: &Path, raw: &str) -> Result<Config, String> {
36+
let config: Config = streamrs::config::toml::parse_from_str(path, raw)?;
37+
validate_config(path, &config)?;
2838
Ok(config)
2939
}
3040

src/config/streamrs_profile.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
1-
use crate::config::streamrs_schema::StreamrsConfig;
1+
use crate::config::current_profile::BLANK_PROFILE;
2+
use crate::config::streamrs_schema::{StreamrsConfig, blank_profile_config};
23
use crate::paths::{config_load_candidates, profile_from_config_path};
34
use std::path::Path;
45

6+
pub fn load_config_for_profile(path: &Path, profile: &str) -> Result<StreamrsConfig, String> {
7+
if profile == BLANK_PROFILE || profile_from_config_path(path) == BLANK_PROFILE {
8+
return Ok(blank_profile_config());
9+
}
10+
if !path.is_file() {
11+
return Ok(StreamrsConfig::default());
12+
}
13+
crate::config::toml::load_from_file(path)
14+
}
15+
516
pub fn load_with_fallbacks(path: &Path) -> Result<Option<StreamrsConfig>, String> {
617
let profile = profile_from_config_path(path);
718
for candidate in config_load_candidates(&profile, path) {

0 commit comments

Comments
 (0)