Skip to content

Commit 49a8fe9

Browse files
committed
Added support to pass arguments to change profile
1 parent 9b58073 commit 49a8fe9

7 files changed

Lines changed: 290 additions & 26 deletions

File tree

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.

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,27 @@ It uses Windows display topology APIs (`DisplayConfig`) to change which outputs
5252
4. Click `Attach` later to bring the display back
5353
5. Use `Save Current Layout` in `Profiles` to store common setups
5454

55+
## Command-Line Profile Switch (Automation)
56+
57+
You can launch Monarch and ask it to apply a specific profile immediately:
58+
59+
```powershell
60+
monarch-desktop.exe -profile "ProfileName"
61+
```
62+
63+
Also supported:
64+
65+
```powershell
66+
monarch-desktop.exe --profile "ProfileName"
67+
monarch-desktop.exe --profile="ProfileName"
68+
```
69+
70+
Notes:
71+
72+
- Useful for tools like Playnite scripts (before/after game launch)
73+
- If Monarch is already running, the new command forwards the profile request to the running instance
74+
- CLI profile argument takes precedence over the configured launch profile in Settings
75+
5576
## Safety Features
5677

5778
- Confirmation timer after layout changes

src-tauri/src/app/events.rs

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -292,30 +292,41 @@ pub fn refresh_tray_menu<R: Runtime>(app: &AppHandle<R>) {
292292
}
293293
}
294294

295-
pub fn handle_profile_apply_external_action<R: Runtime>(app: &AppHandle<R>, name: &str) {
295+
pub fn apply_profile_external_action_result<R: Runtime>(
296+
app: &AppHandle<R>,
297+
name: &str,
298+
) -> Result<(), String> {
296299
let state = app.state::<MonarchAppState>();
297-
let lock = state.0.lock();
298-
if let Ok(mut guard) = lock {
299-
if guard.manager.apply_profile(name).is_ok() {
300-
let pending_timeout = guard.manager.pending_confirmation_remaining();
301-
let auto_confirmed =
302-
pending_timeout.is_some() && guard.manager.confirm_current_layout().is_ok();
303-
drop(guard);
304-
let _ = shortcuts::sync_global_shortcuts(app);
305-
refresh_tray_menu(app);
306-
emit_state_changed(app);
307-
308-
if let Some(timeout) = pending_timeout.filter(|_| !auto_confirmed) {
309-
emit_confirmation(
310-
app,
311-
ConfirmationEvent::Applied {
312-
timeout_ms: timeout.as_millis() as u64,
313-
},
314-
);
315-
spawn_confirmation_watchdog(app.clone(), timeout);
316-
}
317-
}
300+
let mut guard = state
301+
.0
302+
.lock()
303+
.map_err(|_| "state mutex poisoned".to_string())?;
304+
guard
305+
.manager
306+
.apply_profile(name)
307+
.map_err(|err| err.to_string())?;
308+
let pending_timeout = guard.manager.pending_confirmation_remaining();
309+
let auto_confirmed = pending_timeout.is_some() && guard.manager.confirm_current_layout().is_ok();
310+
drop(guard);
311+
312+
let _ = shortcuts::sync_global_shortcuts(app);
313+
refresh_tray_menu(app);
314+
emit_state_changed(app);
315+
316+
if let Some(timeout) = pending_timeout.filter(|_| !auto_confirmed) {
317+
emit_confirmation(
318+
app,
319+
ConfirmationEvent::Applied {
320+
timeout_ms: timeout.as_millis() as u64,
321+
},
322+
);
323+
spawn_confirmation_watchdog(app.clone(), timeout);
318324
}
325+
Ok(())
326+
}
327+
328+
pub fn handle_profile_apply_external_action<R: Runtime>(app: &AppHandle<R>, name: &str) {
329+
let _ = apply_profile_external_action_result(app, name);
319330
}
320331

321332
pub fn handle_toggle_display_external_action<R: Runtime>(app: &AppHandle<R>, display_key: &str) {

src-tauri/src/app/ipc.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
use std::io::{BufRead, BufReader, Write};
2+
use std::net::{TcpListener, TcpStream};
3+
use std::time::Duration;
4+
5+
use serde::{Deserialize, Serialize};
6+
use tauri::{AppHandle, Runtime};
7+
8+
use crate::app::events;
9+
10+
const IPC_BIND_ADDR: &str = "127.0.0.1:42197";
11+
const IPC_IO_TIMEOUT: Duration = Duration::from_secs(3);
12+
13+
#[derive(Serialize, Deserialize)]
14+
#[serde(tag = "type", rename_all = "snake_case")]
15+
enum IpcRequest {
16+
ApplyProfile { name: String },
17+
}
18+
19+
#[derive(Serialize, Deserialize)]
20+
struct IpcResponse {
21+
ok: bool,
22+
error: Option<String>,
23+
}
24+
25+
pub fn send_apply_profile_request(profile_name: &str) -> Result<(), String> {
26+
let mut stream = TcpStream::connect(IPC_BIND_ADDR)
27+
.map_err(|err| format!("failed to connect to running Monarch instance: {err}"))?;
28+
stream
29+
.set_read_timeout(Some(IPC_IO_TIMEOUT))
30+
.map_err(|err| format!("failed to set IPC read timeout: {err}"))?;
31+
stream
32+
.set_write_timeout(Some(IPC_IO_TIMEOUT))
33+
.map_err(|err| format!("failed to set IPC write timeout: {err}"))?;
34+
35+
let request = IpcRequest::ApplyProfile {
36+
name: profile_name.to_string(),
37+
};
38+
let mut request_bytes =
39+
serde_json::to_vec(&request).map_err(|err| format!("failed to encode IPC request: {err}"))?;
40+
request_bytes.push(b'\n');
41+
stream
42+
.write_all(&request_bytes)
43+
.map_err(|err| format!("failed to send IPC request: {err}"))?;
44+
45+
let mut response_line = String::new();
46+
let mut reader = BufReader::new(stream);
47+
reader
48+
.read_line(&mut response_line)
49+
.map_err(|err| format!("failed to read IPC response: {err}"))?;
50+
51+
let response_line = response_line.trim();
52+
if response_line.is_empty() {
53+
return Err("received empty IPC response".to_string());
54+
}
55+
56+
let response: IpcResponse = serde_json::from_str(response_line)
57+
.map_err(|err| format!("failed to decode IPC response: {err}"))?;
58+
if response.ok {
59+
Ok(())
60+
} else {
61+
Err(response
62+
.error
63+
.unwrap_or_else(|| "running instance rejected IPC command".to_string()))
64+
}
65+
}
66+
67+
pub fn spawn_listener<R: Runtime>(app: AppHandle<R>) {
68+
std::thread::spawn(move || {
69+
let listener = match TcpListener::bind(IPC_BIND_ADDR) {
70+
Ok(listener) => listener,
71+
Err(err) => {
72+
eprintln!("Monarch IPC listener bind failed on {IPC_BIND_ADDR}: {err}");
73+
return;
74+
}
75+
};
76+
77+
for stream in listener.incoming() {
78+
match stream {
79+
Ok(stream) => {
80+
if let Err(err) = handle_client_stream(&app, stream) {
81+
eprintln!("Monarch IPC request failed: {err}");
82+
}
83+
}
84+
Err(err) => {
85+
eprintln!("Monarch IPC incoming connection failed: {err}");
86+
}
87+
}
88+
}
89+
});
90+
}
91+
92+
fn handle_client_stream<R: Runtime>(app: &AppHandle<R>, mut stream: TcpStream) -> Result<(), String> {
93+
stream
94+
.set_read_timeout(Some(IPC_IO_TIMEOUT))
95+
.map_err(|err| format!("failed to set client read timeout: {err}"))?;
96+
stream
97+
.set_write_timeout(Some(IPC_IO_TIMEOUT))
98+
.map_err(|err| format!("failed to set client write timeout: {err}"))?;
99+
100+
let mut request_line = String::new();
101+
let mut reader = BufReader::new(
102+
stream
103+
.try_clone()
104+
.map_err(|err| format!("failed to clone client stream: {err}"))?,
105+
);
106+
reader
107+
.read_line(&mut request_line)
108+
.map_err(|err| format!("failed to read IPC request: {err}"))?;
109+
110+
let request = serde_json::from_str::<IpcRequest>(request_line.trim())
111+
.map_err(|err| format!("invalid IPC request payload: {err}"))?;
112+
113+
let result = match request {
114+
IpcRequest::ApplyProfile { name } => events::apply_profile_external_action_result(app, &name),
115+
};
116+
117+
let response = match result {
118+
Ok(()) => IpcResponse {
119+
ok: true,
120+
error: None,
121+
},
122+
Err(err) => IpcResponse {
123+
ok: false,
124+
error: Some(err),
125+
},
126+
};
127+
128+
let mut response_bytes = serde_json::to_vec(&response)
129+
.map_err(|err| format!("failed to encode IPC response: {err}"))?;
130+
response_bytes.push(b'\n');
131+
stream
132+
.write_all(&response_bytes)
133+
.map_err(|err| format!("failed to send IPC response: {err}"))?;
134+
Ok(())
135+
}

src-tauri/src/app/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod commands;
22
pub mod events;
3+
pub mod ipc;
34
pub mod shortcuts;
45
pub mod single_instance;
56
pub mod state;

src-tauri/src/app/startup.rs

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ mod imp {
1313
std::env::args_os().any(|arg| arg == START_HIDDEN_ARG)
1414
}
1515

16+
pub fn requested_profile_name() -> Option<String> {
17+
super::parse_profile_name_from_args(
18+
std::env::args_os()
19+
.skip(1)
20+
.map(|arg| arg.to_string_lossy().into_owned()),
21+
)
22+
}
23+
1624
pub fn sync_start_with_windows(enabled: bool) -> Result<(), String> {
1725
if enabled {
1826
create_run_key_entry()
@@ -110,9 +118,88 @@ mod imp {
110118
false
111119
}
112120

121+
pub fn requested_profile_name() -> Option<String> {
122+
super::parse_profile_name_from_args(std::env::args().skip(1))
123+
}
124+
113125
pub fn sync_start_with_windows(_enabled: bool) -> Result<(), String> {
114126
Ok(())
115127
}
116128
}
117129

118-
pub use imp::{should_start_hidden, sync_start_with_windows};
130+
pub use imp::{requested_profile_name, should_start_hidden, sync_start_with_windows};
131+
132+
fn parse_profile_name_from_args<I>(args: I) -> Option<String>
133+
where
134+
I: IntoIterator<Item = String>,
135+
{
136+
let mut args = args.into_iter();
137+
while let Some(arg) = args.next() {
138+
let trimmed = arg.trim();
139+
if trimmed.is_empty() {
140+
continue;
141+
}
142+
143+
if is_profile_flag(trimmed) {
144+
let next = args.next()?;
145+
let profile_name = next.trim();
146+
if profile_name.is_empty() {
147+
return None;
148+
}
149+
return Some(profile_name.to_string());
150+
}
151+
152+
if let Some(profile_name) = parse_profile_equals_flag(trimmed) {
153+
return Some(profile_name);
154+
}
155+
}
156+
157+
None
158+
}
159+
160+
fn is_profile_flag(value: &str) -> bool {
161+
value.eq_ignore_ascii_case("-profile")
162+
|| value.eq_ignore_ascii_case("--profile")
163+
|| value.eq_ignore_ascii_case("/profile")
164+
}
165+
166+
fn parse_profile_equals_flag(value: &str) -> Option<String> {
167+
if !value.to_ascii_lowercase().starts_with("--profile=")
168+
&& !value.to_ascii_lowercase().starts_with("-profile=")
169+
&& !value.to_ascii_lowercase().starts_with("/profile=")
170+
{
171+
return None;
172+
}
173+
let (_, profile_name) = value.split_once('=')?;
174+
let profile_name = profile_name.trim();
175+
if profile_name.is_empty() {
176+
return None;
177+
}
178+
Some(profile_name.to_string())
179+
}
180+
181+
#[cfg(test)]
182+
mod tests {
183+
use super::parse_profile_name_from_args;
184+
185+
#[test]
186+
fn parses_profile_from_short_flag() {
187+
let args = vec!["-profile".to_string(), "Game Mode".to_string()];
188+
assert_eq!(
189+
parse_profile_name_from_args(args),
190+
Some("Game Mode".to_string())
191+
);
192+
}
193+
194+
#[test]
195+
fn parses_profile_from_long_equals_flag() {
196+
let args = vec!["--profile=Work".to_string()];
197+
assert_eq!(parse_profile_name_from_args(args), Some("Work".to_string()));
198+
}
199+
200+
#[test]
201+
fn returns_none_when_profile_flag_missing_value() {
202+
let args = vec!["-profile".to_string()];
203+
assert_eq!(parse_profile_name_from_args(args), None);
204+
}
205+
}

src-tauri/src/app/state.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ pub fn run_app() {
1818
let _single_instance_guard = match crate::app::single_instance::try_acquire() {
1919
Ok(Some(guard)) => guard,
2020
Ok(None) => {
21-
eprintln!("Monarch is already running.");
21+
if let Some(profile_name) = startup::requested_profile_name() {
22+
if let Err(err) = crate::app::ipc::send_apply_profile_request(&profile_name) {
23+
eprintln!("Monarch is already running and IPC profile apply failed: {err}");
24+
}
25+
} else {
26+
eprintln!("Monarch is already running.");
27+
}
2228
return;
2329
}
2430
Err(err) => {
@@ -35,7 +41,9 @@ pub fn run_app() {
3541
let mut manager =
3642
MonarchDisplayManager::new(backend, store).map_err(|err| err.to_string())?;
3743
let should_start_hidden = startup::should_start_hidden();
38-
let startup_profile_name = manager.settings().startup_profile_name.clone();
44+
let requested_profile_name = startup::requested_profile_name();
45+
let startup_profile_name = requested_profile_name
46+
.or_else(|| manager.settings().startup_profile_name.clone());
3947

4048
if let Some(profile_name) = startup_profile_name {
4149
match manager.apply_profile(&profile_name) {
@@ -69,6 +77,7 @@ pub fn run_app() {
6977
events::refresh_tray_menu(&app.handle());
7078
events::spawn_color_state_watchdog(app.handle().clone());
7179
events::spawn_topology_state_watchdog(app.handle().clone());
80+
crate::app::ipc::spawn_listener(app.handle().clone());
7281

7382
if let Some(window) = app.get_webview_window("main") {
7483
if should_start_hidden {

0 commit comments

Comments
 (0)