Compare commits

...
Author SHA1 Message Date
Ludovic Marie 9ad735b9d7 Merge pull request #2625 from ludovic111/feat/launcher-shortcuts
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:51:21 +01:00
Ludovic Marie 6f19f06634 Merge pull request #2350 from kacpherek/fix/mining-hit-sound-level-events
🤖 Agent: verified against vanilla 26.3 (mining hit sounds now come from the level events, once per hit as MultiPlayerGameMode/LevelRenderer do). I merged main into the branch (one conflict in level_particles.rs, both functions kept). Tested on a tree of main + this PR + #2279: the 33 harnesses its files reach passed, and two real games over LAN (host in-frame and on its server thread) heard 5-7 stone hits per block, with digging, cracks, instant digging, death-while-mining, respawn and hurt checks passing. Thanks @kacpherek!
2026-10-27 06:48:05 +01:00
Ludovic Marie 89225ec081 Merge pull request #2279 from kacpherek/fix/2276-dead-hurt-tilt
🤖 Agent: verified against vanilla 26.3: LivingEntity.baseTick counts hurtTime down for a dead or riding player too, so the hurt tilt no longer restarts every frame and shakes the camera on the server-thread path. Tested on a tree of main + #2350 + this PR: the 33 harnesses its files reach passed, and the LAN hurt, death-while-mining and respawn checks passed with the host in-frame and on its server thread. Thanks @kacpherek!
2026-10-27 06:39:59 +01:00
Ludovic Marie 6044c6ff88 Merge pull request #2623 from ludovic111/feat/launcher-hide-on-play
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:30:43 +01:00
Ludovic Marie d74d39cb05 Merge pull request #2622 from ludovic111/feat/launcher-console
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:21:26 +01:00
Ludovic MarieandClaude Opus 5.5 73aef0ce8e Merge main into fix/mining-hit-sound-level-events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:17:53 +01:00
Ludovic MarieandClaude Opus 5.5 82c8398c6b launcher: desktop shortcut that plays an instance
Home's Instance row gets Shortcut / Remove shortcut, and the CLI and MCP
instance shortcut [instance] [--remove]. The shortcut runs
horizon-launcher play --instance <id>: a .desktop file on Linux (and in
the applications menu), a script app with Horizon's icon on macOS, and a
Shell Link written from Microsoft's published MS-SHLLINK layout on
Windows. HORIZON_LAUNCHER_DESKTOP points it at a scratch folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:02:20 +01:00
Ludovic MarieandClaude Opus 5.5 cde33a8e42 launcher: minimise or hide the window while the game runs
Settings > While playing: Stay (the default), Minimise, or Hide (macOS).
The window goes away when the game starts and comes back to the front
when it closes, with the run's play time, errors and log. CLI and MCP:
settings while-playing stay|minimize|hide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:51:54 +01:00
Ludovic MarieandClaude Opus 5.5 0ccf3e110b launcher: live game console with search, follow, copy and share log
Home's Console shows the game's output of this run as it comes, with a
search, Follow, Copy and Open logs folder. Share log uploads it to mclo.gs
only after the player confirms, with the home folder, user name and
account names taken out first, and can delete the paste again. CLI:
share-log and unshare-log (MCP tools, the second needs confirm).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:44:53 +01:00
Kacpherek 0c127ec1d9 Stub breaking_block_effects in the remote mining test fixture 2026-10-26 19:23:34 +01:00
Kacpherek 7f5e3384b3 Play mining hit sounds once, from the server's destroy level events
The server now sends level events 2019 and 2020 every tick a player digs
(ServerPlayerGameMode.tick), and clients handle them like LevelEventHandler:
a crack on the face, plus the hit sound for 2020. A remote client plays only
the first hit itself and no longer adds its own sound every fourth tick, so
the hit was heard twice, out of phase, for every block.
2026-10-26 19:07:11 +01:00
Kacpherek 6f4c2cfbbc Count the hurt tilt down while the player is dead or riding 2026-10-26 16:16:19 +01:00
24 changed files with 1705 additions and 50 deletions

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
launcher, `HORIZON_LAUNCHER_TRASH` and `HORIZON_LAUNCHER_CLIPBOARD` pointing into the scratch home. Never write to
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
+30 -15
View File
@@ -2990,6 +2990,16 @@ impl Game<'_> {
data,
} => self.sculk_charge_event(pos, data),
LevelEvent::LevelEvent { id: 3007, pos, .. } => self.shriek_event(pos),
// LevelEventHandler 2019 and 2020: the crack on a face of the
// block being mined, with its hit sound for 2020.
LevelEvent::LevelEvent {
id: id @ (2019 | 2020),
pos,
data,
} => {
let face = horizon_core::geometry::Direction::ALL[data.clamp(0, 5) as usize];
self.breaking_block_effects([pos.x, pos.y, pos.z], face.normal(), id == 2020);
}
LevelEvent::LevelEvent { id: 2004, pos, .. } => {
self.spawner_burst([pos.x, pos.y, pos.z])
}
@@ -4241,6 +4251,17 @@ impl Game<'_> {
_ => {}
}
}
/// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every
/// fourth tick since the START (the hit sound), else 2019 (the crack only),
/// for everyone around, the digger included (`levelEvent(null, ...)`).
fn destroy_progress_event(&mut self, pos: [i32; 3], face: [i32; 3], ticks: u32) {
let id = if ticks.is_multiple_of(4) { 2020 } else { 2019 };
let direction = horizon_core::geometry::Direction::ALL
.iter()
.position(|d| d.normal() == face)
.unwrap_or(1);
self.level_event_at(id, pos, direction as i32);
}
fn tick_mining(&mut self) -> Result<()> {
if self.destroy_delay > 0 {
self.destroy_delay -= 1;
@@ -4284,6 +4305,11 @@ impl Game<'_> {
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let facts = self.res.facts().get(state);
if facts.hardness < 0.0 {
// The server still counts its destroy ticks on an unbreakable block.
let m = self.mining.as_mut().ok_or("mining")?;
m.ticks += 1;
let (pos, ticks) = (m.pos, m.ticks);
self.destroy_progress_event(pos, hit.face, ticks);
return Ok(());
}
// Player.getDestroySpeed / BlockBehaviour.getDestroyProgress.
@@ -4314,26 +4340,15 @@ impl Game<'_> {
} else {
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
};
let hit_sound = facts.sounds.clone();
self.swing_arm();
let m = self.mining.as_mut().ok_or("mining")?;
m.progress += progress;
m.ticks += 1;
let crack = (m.pos, hit.face);
if m.ticks % 4 == 1 {
let c = [
f64::from(m.pos[0]) + 0.5,
f64::from(m.pos[1]) + 0.5,
f64::from(m.pos[2]) + 0.5,
];
self.audio.play(
&hit_sound.hit,
Some(c),
(hit_sound.volume + 1.0) / 8.0,
hit_sound.pitch * 0.5,
);
// An instant break at START sends no destroy events.
if m.progress < 1.0 || m.ticks > 1 {
let (pos, ticks) = (m.pos, m.ticks);
self.destroy_progress_event(pos, hit.face, ticks);
}
self.crack_particle(crack.0, crack.1, state);
let m = self.mining.as_mut().ok_or("mining")?;
// ServerPlayerGameMode breaks a player's block at once only when it is instant (START's
// insta-mine); otherwise it waits for the client's STOP_DESTROY_BLOCK
+3 -2
View File
@@ -1267,8 +1267,9 @@ impl Game<'_> {
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
// The acting guest does not predict the egg's teleport
// particles, bone meal's growth, or anvil result sounds
// (these levelEvent calls name no excluded player).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
// (these levelEvent calls name no excluded player), nor
// the digger's own crack and hit sound (2019, 2020).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020) {
None
} else {
self.lan_acting
@@ -135,6 +135,30 @@ impl Game<'_> {
// Each guest plays it from the global packet.
self.audio.play_unshared(sound, at, volume, 1.0);
}
/// `ClientLevel.addBreakingBlockEffects`: the block's hit sound (only for
/// `play_sound`) and one crumb on the face being mined; nothing for air.
pub(super) fn breaking_block_effects(
&mut self,
pos: [i32; 3],
face: [i32; 3],
play_sound: bool,
) {
let state = self.world.block(pos[0], pos[1], pos[2]);
if self.res.facts().is_air(state) {
return;
}
if play_sound {
let sounds = self.res.facts().get(state).sounds.clone();
// Each guest plays it from the level event itself.
self.audio.play_unshared(
&sounds.hit,
Some(pos.map(|v| f64::from(v) + 0.5)),
(sounds.volume + 1.0) / 8.0,
sounds.pitch * 0.5,
);
}
self.crack_particle(pos, face, state);
}
/// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007
/// with `InstantEffect`): eight splash potion crumbs, and a hundred
/// spell particles in the potion's colour `data`, flung out in a disc.
+8 -14
View File
@@ -3333,6 +3333,11 @@ impl Game<'_> {
resources: self.motion,
}
.controlled_simulation_tick(keys)?;
} else {
// LivingEntity.baseTick counts hurtTime down for a dead or riding
// player too (controlled_simulation_tick does it otherwise): left
// at 10, the hurt tilt restarted every frame and shook the camera.
player.health.advance_clock();
}
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
// jump, a spectator, landing) is sent to the server.
@@ -3930,6 +3935,9 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.destroying = Some((hit.pos, face, 0.0));
}
// startDestroyBlock plays the first hit and crack itself; the
// server's level events 2019 and 2020 do the rest.
self.breaking_block_effects(hit.pos, hit.face, true);
self.mining = Some(Mining {
pos: hit.pos,
progress: 0.0,
@@ -3942,20 +3950,6 @@ impl Game<'_> {
};
let done = done + progress;
let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1;
if ticks % 4 == 1 {
let c = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio.play(
&facts.sounds.hit,
Some(c),
(facts.sounds.volume + 1.0) / 8.0,
facts.sounds.pitch * 0.5,
);
}
self.crack_particle(pos, hit.face, state);
if done >= 1.0 {
let seq = self.next_sequence();
self.predict_break(pos, state, seq);
+33 -2
View File
@@ -134,11 +134,11 @@ MCP: `account_offline`.
## `horizon-launcher settings`
Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off, proxy http://host:port ("default" removes it).
Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off, proxy http://host:port ("default" removes it), while-playing stay|minimize|hide (what the window does while the game runs).
horizon-launcher settings [KEY] [VALUE]
- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation, proxy)
- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation, proxy, while-playing)
- `VALUE`: The new value (with KEY)
MCP: `settings`.
@@ -153,6 +153,26 @@ Show or set the extra arguments the game starts with (for example --server-threa
MCP: `game_args`.
## `horizon-launcher share-log`
Upload an instance's latest.log to mclo.gs (a paste service for Minecraft logs) and print its link, for a bug report: only when the player asks. The home folder, the user name and the account names are taken out first; the link expires after 90 days unread.
horizon-launcher share-log
- `--instance`: The instance (default: the selected one)
MCP: `share_log`, only when the player asks.
## `horizon-launcher unshare-log`
Delete a log shared with share-log: the "<id>:<token>" it printed.
horizon-launcher unshare-log <SHARED>
- `SHARED`
MCP: `unshare_log`, needs `confirm: true`.
## `horizon-launcher logs`
Print the end of the game's log (logs/latest.log in the game folder), or its newest crash report.
@@ -344,6 +364,17 @@ Export an instance (or a port) to share: its settings, packs, Horizon mods and t
MCP: `instance_export`.
## `horizon-launcher instance shortcut`
Put a shortcut on the desktop that plays an instance (`play --instance`): a .desktop file (also in the applications menu) on Linux, an app on macOS, a .lnk on Windows.
horizon-launcher instance shortcut [INSTANCE]
- `INSTANCE`: The instance (default: the selected one)
- `--remove`: Move the instance's shortcuts to the trash instead
MCP: `instance_shortcut`.
## `horizon-launcher instance import`
Import what a file is, as dropping it on the window does: a shared instance (a .mrpack without mod loaders, such as `instance export` writes, or a game folder zip) becomes a new instance; a Java modpack goes to the Modpacks import; a resource or shader pack into the instance (default: the selected one).
+18 -2
View File
@@ -100,6 +100,20 @@ The opening, then Play ([clip](docs/opening.webm)):
pack into the selected instance. A data pack is pointed at the world rows, and the Packs section
and the world rows keep taking their own drops. A note in the corner says what happened.
`horizon-launcher instance import <file>`.
- **Desktop shortcut**: Home's Instance row has **Shortcut**: a shortcut on the desktop that plays
the selected instance without opening the launcher (`horizon-launcher play --instance <id>`).
A `.desktop` file on Linux (also put in the applications menu, and marked trusted with `gio`
where it exists), a small app with Horizon's icon on macOS, a `.lnk` on Windows
(`horizon_launcher::shortcut`). **Remove shortcut** moves it to the trash; it is found by the
instance id inside it, so a rename doesn't lose it. `horizon-launcher instance shortcut
[instance] [--remove]`; `HORIZON_LAUNCHER_DESKTOP` points it at another folder.
- **Console**: Home's Console shows the game's output of this run as it comes: a search (every
word, any case), Follow to keep the newest line in view, Copy, and Open logs folder. Warnings
and errors stand out. **Share log…** uploads the log to [mclo.gs](https://mclo.gs), a paste
service for Minecraft logs, only after the player confirms; the home folder, the user name and
the account names are taken out first (`horizon_launcher::game_log`), and **Delete it** removes
the paste. `horizon-launcher share-log [--instance]` and `unshare-log <id:token>`;
`HORIZON_LAUNCHER_PASTE_URL` points it at a test server.
- **Worlds**: Home lists the selected instance's worlds (picture, mode, version, last played,
size). **Back up** zips one into the instance's `backups/` as the game's Make Backup does
(`<date>_<time>_<folder>.zip`); **Copy** puts "<name> (1)" beside it; **Delete** moves it to the
@@ -140,8 +154,10 @@ page `servers.json` (the list), `servers/<name>/` (the servers created there) an
- **Settings**: install new Horizon versions automatically (on unless you turn it off; the
selected version only moves to the new one if you were on the newest), the opening animation,
the game's extra command-line arguments, an HTTP proxy for downloads, and the launcher's own
version and update state.
what the window does while you play (stays by default; Minimise, or Hide on macOS, puts it away
when the game starts and brings it back to the front when the game closes; `settings
while-playing stay|minimize|hide`), the game's extra command-line arguments, an HTTP proxy for
downloads, and the launcher's own version and update state.
- **Updates**: there is one channel. Every release is published as the release, GitHub's latest,
once its smoke test passed (docs/release-flow.md), and the launcher installs it. The game
pre-releases of the old Alpha channel (v0.2.1 on) stay on the Versions page to install
+78
View File
@@ -2515,6 +2515,51 @@ pub enum Appearance {
System,
}
/// What the launcher's window does while the game runs, as the player picks it in Settings.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WhilePlaying {
/// The window stays where it is.
#[default]
Stay,
/// The window goes to the Dock or the taskbar, and comes back when the game closes.
Minimize,
/// The launcher hides (macOS only: elsewhere it minimises), and comes back when the game
/// closes.
Hide,
}
impl WhilePlaying {
pub const ALL: [WhilePlaying; 3] = [Self::Stay, Self::Minimize, Self::Hide];
/// Its name in `settings.json` and on the command line.
pub fn name(self) -> &'static str {
match self {
Self::Stay => "stay",
Self::Minimize => "minimize",
Self::Hide => "hide",
}
}
pub fn parse(name: &str) -> Option<Self> {
let name = name.trim().to_ascii_lowercase();
let name = match name.as_str() {
"minimise" => "minimize",
"keep" | "open" | "off" => "stay",
n => n,
};
Self::ALL.into_iter().find(|w| w.name() == name)
}
/// What this computer does for it: only macOS can hide a whole app.
pub fn here(self) -> Self {
match self {
Self::Hide if !cfg!(target_os = "macos") => Self::Minimize,
w => w,
}
}
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
pub struct Settings {
pub selected: Option<String>,
@@ -2556,6 +2601,9 @@ pub struct Settings {
/// The player hid the sidebar's partner banner. The Servers page keeps its own.
#[serde(default)]
pub partner_hidden: bool,
/// What the window does while the game runs. Stays by default.
#[serde(default)]
pub while_playing: WhilePlaying,
}
impl Settings {
@@ -2574,6 +2622,12 @@ impl Settings {
{
fields.remove("appearance");
}
if fields
.get("while_playing")
.is_some_and(|w| serde_json::from_value::<WhilePlaying>(w.clone()).is_err())
{
fields.remove("while_playing");
}
}
serde_json::from_value(value).unwrap_or_default()
}
@@ -2887,6 +2941,30 @@ mod tests {
assert_eq!(CHECK_EVERY.as_secs(), 30 * 60);
}
#[test]
fn the_window_stays_while_playing_unless_picked() {
assert_eq!(Settings::parse("{}").while_playing, WhilePlaying::Stay);
let hide = Settings::parse(r#"{"while_playing": "hide", "game_args": "--x"}"#);
assert_eq!(hide.while_playing, WhilePlaying::Hide);
let future = Settings::parse(r#"{"while_playing": "fade", "game_args": "--x"}"#);
assert_eq!(
(future.while_playing, future.game_args.as_str()),
(WhilePlaying::Stay, "--x")
);
assert_eq!(
WhilePlaying::parse("Minimise"),
Some(WhilePlaying::Minimize)
);
assert_eq!(WhilePlaying::parse("off"), Some(WhilePlaying::Stay));
assert_eq!(WhilePlaying::parse("close"), None);
for w in WhilePlaying::ALL {
let text = serde_json::to_string(&w).unwrap();
assert_eq!(text, format!("\"{}\"", w.name()));
}
let here = WhilePlaying::Hide.here();
assert_eq!(here == WhilePlaying::Hide, cfg!(target_os = "macos"));
}
#[test]
fn the_appearance_is_dark_unless_picked_and_an_unknown_one_keeps_the_rest() {
assert_eq!(Settings::parse("{}").appearance, Appearance::Dark);
+84 -2
View File
@@ -198,6 +198,15 @@ enum InstanceCmd {
#[arg(long)]
out: Option<PathBuf>,
},
/// Put a shortcut on the desktop that plays an instance (`play --instance`): a .desktop
/// file (also in the applications menu) on Linux, an app on macOS, a .lnk on Windows
Shortcut {
/// The instance (default: the selected one)
instance: Option<String>,
/// Move the instance's shortcuts to the trash instead
#[arg(long)]
remove: bool,
},
/// Import what a file is, as dropping it on the window does: a shared instance (a .mrpack
/// without mod loaders, such as `instance export` writes, or a game folder zip) becomes a
/// new instance; a Java modpack goes to the Modpacks import; a resource or shader pack into
@@ -549,7 +558,8 @@ enum Cmd {
},
/// Show the launcher's settings, or set one (the Settings page): selected-version TAG,
/// minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions
/// on|off, opening-animation on|off, proxy http://host:port ("default" removes it)
/// on|off, opening-animation on|off, proxy http://host:port ("default" removes it),
/// while-playing stay|minimize|hide (what the window does while the game runs)
Settings {
#[arg(value_parser = SETTING_KEYS)]
key: Option<String>,
@@ -564,6 +574,16 @@ enum Cmd {
#[arg(allow_hyphen_values = true)]
args: Option<String>,
},
/// Upload an instance's latest.log to mclo.gs (a paste service for Minecraft logs) and print
/// its link, for a bug report: only when the player asks. The home folder, the user name and
/// the account names are taken out first; the link expires after 90 days unread
ShareLog {
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
},
/// Delete a log shared with share-log: the "<id>:<token>" it printed
UnshareLog { shared: String },
/// Print the end of the game's log (logs/latest.log in the game folder), or its newest crash
/// report
Logs {
@@ -676,13 +696,14 @@ pub fn command() -> clap::Command {
pub const AI_COMMANDS: [&str; 2] = ["mcp", "agent-config"];
/// The keys `settings` sets.
const SETTING_KEYS: [&str; 6] = [
const SETTING_KEYS: [&str; 7] = [
"selected-version",
"minecraft-dir",
"server-dir",
"install-new-versions",
"opening-animation",
"proxy",
"while-playing",
];
fn on_off(value: &str) -> Result<bool, String> {
@@ -701,6 +722,10 @@ fn set_setting(s: &mut backend::Settings, key: &str, value: &str) -> Result<(),
"server-dir" => s.server_dir = folder(value),
"install-new-versions" => s.install_new_versions = Some(on_off(value)?),
"opening-animation" => s.skip_opening = !on_off(value)?,
"while-playing" => {
s.while_playing = backend::WhilePlaying::parse(value)
.ok_or_else(|| format!("{value}: say stay, minimize or hide"))?
}
"proxy" => {
if value == "default" || value.is_empty() {
s.proxy = None;
@@ -726,6 +751,7 @@ fn settings_json(s: &backend::Settings) -> serde_json::Value {
"game_args": s.game_args,
"proxy": s.proxy,
"desktop_entry": s.desktop_entry.unwrap_or(true),
"while_playing": s.while_playing.name(),
"no_ai": backend::no_ai(),
})
}
@@ -1056,6 +1082,41 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
println!("Game arguments: {line}");
}
}
Cmd::ShareLog { instance } => {
use horizon_launcher::game_log;
let i = match instance {
Some(name) => instances::find(&name)?,
None => instances::selected(),
};
let path = i.home.join("logs/latest.log");
let text =
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let names = game_log::names(&[&i.home, &backend::game_home()]);
let text = game_log::private(&text, dirs::home_dir().as_deref(), &names);
let shared = game_log::share(&text)?;
if as_json {
json(serde_json::json!({
"url": shared.url, "delete": format!("{}:{}", shared.id, shared.token),
}));
} else {
println!("{}", shared.url);
println!(
"Delete it: horizon-launcher unshare-log {}:{}",
shared.id, shared.token
);
}
}
Cmd::UnshareLog { shared } => {
let (id, token) = shared
.split_once(':')
.ok_or("Give the \"<id>:<token>\" share-log printed.")?;
horizon_launcher::game_log::unshare(&horizon_launcher::game_log::Shared {
url: String::new(),
id: id.into(),
token: token.into(),
})?;
println!("Deleted the shared log {id}.");
}
Cmd::Logs { lines, crash } => {
let (path, text) = backend::read_logs(lines, crash)?;
if as_json {
@@ -1775,6 +1836,27 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
};
changed(instances::edit(&instance, |m| m.version = pin)?)
}
InstanceCmd::Shortcut { instance, remove } => {
use horizon_launcher::shortcut;
let i = match instance {
Some(name) => instances::find(&name)?,
None => instances::selected(),
};
let paths = match remove {
false => shortcut::make(backend::exe_path(), &i.id, &i.name)?,
true => shortcut::remove(&i.id)?,
};
if as_json {
json(serde_json::json!({ "instance": i.id, "paths": paths }));
} else if remove && paths.is_empty() {
println!("{} has no shortcut.", i.name);
} else {
let did = if remove { "Moved to the trash" } else { "Made" };
for path in paths {
println!("{did}: {}", path.display());
}
}
}
InstanceCmd::Import { file, instance } => {
use horizon_launcher::import::{self, Dropped};
use horizon_launcher::modpacks;
+2
View File
@@ -31,6 +31,7 @@ pub const DESTRUCTIVE: &[&str] = &[
"screenshots delete",
"packs remove",
"worlds datapack-remove",
"unshare-log",
];
/// Commands that send something or reach other people: only when the player asks.
@@ -40,6 +41,7 @@ pub const OPEN_WORLD: &[&str] = &[
"data bug",
"data benchmark",
"servers create",
"share-log",
];
/// A generated tool: its name, the CLI words it runs and the clap command behind them.
+320
View File
@@ -0,0 +1,320 @@
//! Home's Console: the game's output of this run as it comes, with a search, Follow (keep the
//! newest line in view), Copy, and Share log (`horizon_launcher::game_log`): an upload to mclo.gs
//! that happens only after the player confirms, without their home folder or names.
use super::servers_page::Field;
use super::*;
use gpui::{ClipboardItem, ScrollHandle};
use horizon_launcher::game_log::{self, Shared};
pub(super) struct Console {
open: bool,
follow: bool,
scroll: ScrollHandle,
/// Lines and search at the last draw: a change means scroll down when following.
seen: (usize, String),
confirm_share: bool,
sharing: bool,
shared: Option<Result<Shared, String>>,
}
impl Default for Console {
fn default() -> Self {
Console {
open: std::env::var("HORIZON_LAUNCHER_CONSOLE").is_ok(),
follow: true,
scroll: ScrollHandle::new(),
seen: (0, String::new()),
confirm_share: false,
sharing: false,
shared: None,
}
}
}
impl Launcher {
/// What Share log sends: this run's lines, else the selected instance's latest.log.
fn log_to_share(&self) -> Result<(String, Vec<std::path::PathBuf>), String> {
let home = self
.client_home
.clone()
.or_else(|| self.selected_instance().map(|i| i.home.clone()))
.unwrap_or_else(backend::game_home);
let text = match self.prepare_log.is_empty() {
false => {
self.prepare_log
.iter()
.cloned()
.collect::<Vec<_>>()
.join("\n")
+ "\n"
}
true => {
let path = home.join("logs/latest.log");
std::fs::read_to_string(&path).map_err(|_| "There is no log to share yet.")?
}
};
Ok((text, vec![home, backend::game_home()]))
}
fn share_log(&mut self, cx: &mut Context<Self>) {
let shared = match self.log_to_share() {
Ok(log) => log,
Err(e) => {
self.console.shared = Some(Err(e));
return;
}
};
self.console.confirm_share = false;
self.console.sharing = true;
let task = cx.background_executor().spawn(async move {
let (text, homes) = shared;
let homes: Vec<&std::path::Path> = homes.iter().map(|h| h.as_path()).collect();
let names = game_log::names(&homes);
game_log::share(&game_log::private(
&text,
dirs::home_dir().as_deref(),
&names,
))
});
cx.spawn(async move |this, cx| {
let done = task.await;
let _ = this.update(cx, |this, cx| {
this.console.sharing = false;
this.console.shared = Some(done);
cx.notify();
});
})
.detach();
}
fn unshare_log(&mut self, cx: &mut Context<Self>) {
let Some(Ok(shared)) = self.console.shared.clone() else {
return;
};
self.console.sharing = true;
let task = cx
.background_executor()
.spawn(async move { game_log::unshare(&shared) });
cx.spawn(async move |this, cx| {
let done = task.await;
let _ = this.update(cx, |this, cx| {
this.console.sharing = false;
this.console.shared = match done {
Ok(()) => None,
Err(e) => Some(Err(e)),
};
cx.notify();
});
})
.detach();
}
/// Home's Console section, once the game has said something (or is running).
pub(super) fn console_section(&mut self, cx: &mut Context<Self>) -> Option<Div> {
let t = self.theme;
if self.prepare_log.is_empty() && self.client.is_none() {
return None;
}
let open = self.console.open;
let toggle = button(
"console-toggle",
if open { "Hide" } else { "Show" },
Kind::Ghost,
&t,
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.console.open = !this.console.open;
cx.notify();
}));
let count = self.prepare_log.len();
let header = div()
.flex()
.items_center()
.justify_between()
.child(section_title("Console", "terminal", &t))
.child(
div()
.flex()
.items_center()
.gap(px(10.))
.child(
text(theme::CAPTION)
.text_color(t.muted)
.child(match self.client {
Some(_) => format!("{count} lines, the game is running"),
None => format!("{count} lines from the last run"),
}),
)
.child(toggle),
);
let mut section = div().flex().flex_col().gap(px(10.)).child(header);
if !open {
return Some(section);
}
let search = self.servers.value(Field::ConsoleSearch).trim().to_string();
let lines: Vec<String> = self
.prepare_log
.iter()
.filter(|l| game_log::matches(l, &search))
.cloned()
.collect();
let now = (count, search);
if self.console.follow && now != self.console.seen {
self.console.scroll.scroll_to_bottom();
}
self.console.seen = now;
let follow = div()
.id("console-follow")
.flex()
.items_center()
.gap(px(8.))
.cursor_pointer()
.child(switch("console-follow-switch", self.console.follow, &t))
.child(text(theme::LABEL).text_color(t.ink_soft).child("Follow"))
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.console.follow = !this.console.follow;
this.console.seen.0 = usize::MAX;
cx.notify();
}));
let copied = lines.join("\n");
let tools = div()
.flex()
.flex_wrap()
.items_center()
.gap(px(10.))
.child(self.field(Field::ConsoleSearch, "Search the log", 260., cx))
.child(follow)
.child(div().flex_1())
.child(
button("console-copy", "Copy", Kind::Ghost, &t).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
copy_to_clipboard(cx, ClipboardItem::new_string(copied.clone()));
this.console.shared = None;
cx.notify();
},
)),
)
.child({
let b = button("console-share", "Share log…", Kind::Ghost, &t);
match self.console.sharing {
true => disabled(b),
false => b.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.console.confirm_share = true;
cx.notify();
})),
}
})
.child({
let logs = self
.client_home
.clone()
.or_else(|| self.selected_instance().map(|i| i.home.clone()))
.unwrap_or_else(backend::game_home)
.join("logs");
button("console-folder", "Open logs folder", Kind::Ghost, &t).on_click(
move |_, _, cx| {
let _ = std::fs::create_dir_all(&logs);
cx.open_with_system(&logs);
},
)
});
section = section.child(tools);
if self.console.confirm_share {
section = section.child(
plate(&t)
.flex()
.items_center()
.gap(px(12.))
.px(px(14.))
.py(px(10.))
.child(div().flex_1().min_w_0().child(
text(theme::CAPTION).text_color(t.ink_soft).child(
"Upload this log to mclo.gs, a paste service for Minecraft logs? \
Anyone with the link can read it, until it is deleted or unread \
for 90 days. Your home folder, user name and account names are \
taken out first.",
),
))
.child(
button("console-share-no", "Cancel", Kind::Ghost, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.console.confirm_share = false;
cx.notify();
}),
),
)
.child(
button("console-share-yes", "Upload", Kind::Secondary, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.share_log(cx);
cx.notify();
}),
),
),
);
}
match &self.console.shared {
_ if self.console.sharing => {
section = section.child(text(theme::CAPTION).text_color(t.muted).child("Sending…"))
}
Some(Ok(shared)) => {
let (url, open_url) = (shared.url.clone(), shared.url.clone());
section = section.child(
div()
.flex()
.items_center()
.gap(px(10.))
.child(
text(theme::CAPTION)
.text_color(t.ink_soft)
.child(format!("Shared: {url}")),
)
.child(
button("console-link-copy", "Copy link", Kind::Ghost, &t).on_click(
move |_, _, cx| {
copy_to_clipboard(cx, ClipboardItem::new_string(url.clone()))
},
),
)
.child(
button("console-link-open", "Open", Kind::Ghost, &t)
.on_click(move |_, _, cx| cx.open_url(&open_url)),
)
.child(
button("console-unshare", "Delete it", Kind::Ghost, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.unshare_log(cx);
cx.notify();
}),
),
),
);
}
Some(Err(e)) => section = section.child(notice(e.clone(), &t)),
None => {}
}
let shown = lines.len().saturating_sub(LOG_LINES);
let body = div()
.id("console-lines")
.track_scroll(&self.console.scroll)
.h(px(320.))
.overflow_y_scroll()
.p(px(10.))
.border_2()
.border_color(t.outline)
.bg(t.well)
.font_family(theme::MONO)
.text_size(px(12.))
.children(lines.into_iter().skip(shown).map(|line| {
let color = if line.contains("/ERROR]") || line.contains("ERROR:") {
t.danger
} else if line.contains("/WARN]") {
t.warn
} else {
t.ink_soft
};
div().text_color(color).child(line)
}));
Some(section.child(body))
}
}
+2 -2
View File
@@ -65,7 +65,7 @@ pub fn data_home() -> Option<PathBuf> {
}
/// Quotes a path for an `Exec` key: inside double quotes, `"`, `` ` ``, `$` and `\` are escaped.
fn quote(path: &Path) -> String {
pub(crate) fn quote(path: &Path) -> String {
let mut out = String::from("\"");
for c in path.to_string_lossy().chars() {
if matches!(c, '"' | '`' | '$' | '\\') {
@@ -215,7 +215,7 @@ pub fn needs_repair_in(data: &Path, path: Option<&OsStr>) -> bool {
/// What the entry starts: a distributor's wrapper (`HORIZON_LAUNCHER_DESKTOP_EXEC`) when one is
/// configured, else the binary at `exe`.
fn target(exe: &Path) -> PathBuf {
pub(crate) fn target(exe: &Path) -> PathBuf {
std::env::var_os("HORIZON_LAUNCHER_DESKTOP_EXEC")
.filter(|path| !path.is_empty())
.map_or_else(|| exe.to_path_buf(), PathBuf::from)
+4 -2
View File
@@ -7,7 +7,8 @@
//! (text as UTF-8, a picture as its PNG or JPEG bytes).
//!
//! Test runs point both into their scratch folders, beside `HORIZON_HOME` and
//! `HORIZON_LAUNCHER_HOME`.
//! `HORIZON_LAUNCHER_HOME`, and `HORIZON_LAUNCHER_DESKTOP` too (`shortcut`: where desktop
//! shortcuts go).
use std::path::{Path, PathBuf};
pub const TRASH_ENV: &str = "HORIZON_LAUNCHER_TRASH";
@@ -22,7 +23,8 @@ pub fn to_trash(path: &Path) -> Result<(), String> {
}
}
fn to_folder(path: &Path, dir: &Path) -> Result<(), String> {
/// Moves `path` into `dir` under a free name: the trash of a test run.
pub fn to_folder(path: &Path, dir: &Path) -> Result<(), String> {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let name = path.file_name().ok_or("Nothing to move")?.to_string_lossy();
let target = (1..)
+161
View File
@@ -0,0 +1,161 @@
//! The game's log as the console shows it, and sharing it.
//!
//! Sharing uploads the log to mclo.gs, and only when the player asks (a click, or `logs share`).
//! mclo.gs is a paste service made for Minecraft logs by Aternos: no account, links expire after
//! 90 days unread, and the uploader gets a token that deletes the paste. Before anything leaves
//! the computer, [`private`] takes out the player's home folder, their user name and their
//! account names.
use std::path::Path;
/// mclo.gs's API; `HORIZON_LAUNCHER_PASTE_URL` points it elsewhere for tests.
const API: &str = "https://api.mclo.gs/1";
const PASTE_ENV: &str = "HORIZON_LAUNCHER_PASTE_URL";
/// mclo.gs keeps at most 25,000 lines and 10 MiB: the end of a longer log is sent.
const MAX_LINES: usize = 25_000;
const MAX_BYTES: usize = 10 << 20;
fn api() -> String {
std::env::var(PASTE_ENV)
.ok()
.filter(|u| !u.is_empty())
.unwrap_or_else(|| API.into())
}
/// Whether a console line matches the search: every word, in any case.
pub fn matches(line: &str, search: &str) -> bool {
let line = line.to_lowercase();
search
.split_whitespace()
.all(|w| line.contains(&w.to_lowercase()))
}
/// The names to take out of a log: the account names, the offline player name kept in each
/// game folder (`horizon-player.txt`), and the computer's user name.
pub fn names(homes: &[&Path]) -> Vec<String> {
let mut names: Vec<String> = crate::account::Accounts::load()
.accounts
.into_iter()
.map(|a| a.name)
.collect();
for home in homes {
if let Ok(name) = std::fs::read_to_string(home.join("horizon-player.txt")) {
names.push(name.trim().to_string());
}
}
for var in ["USER", "USERNAME"] {
if let Ok(user) = std::env::var(var) {
names.push(user);
}
}
if let Some(user) =
dirs::home_dir().and_then(|h| h.file_name().map(|n| n.to_string_lossy().into_owned()))
{
names.push(user);
}
names.retain(|n| n.chars().count() >= 3);
names.sort_by_key(|n| std::cmp::Reverse(n.len()));
names.dedup();
names
}
/// The log without the home folder (`~` instead, with either slash) and without `names` (each
/// becomes `<player>`), as whole words in any case.
pub fn private(text: &str, home: Option<&Path>, names: &[String]) -> String {
let mut out = text.to_string();
if let Some(home) = home
.map(|h| h.to_string_lossy().into_owned())
.filter(|h| h.len() > 1)
{
out = out.replace(&home, "~");
out = out.replace(&home.replace('\\', "/"), "~");
out = out.replace(&home.replace('/', "\\"), "~");
}
for name in names {
out = replace_word(&out, name, "<player>");
}
out
}
/// Replaces `word` (any case) where it isn't part of a longer name.
fn replace_word(text: &str, word: &str, with: &str) -> String {
let lower = text.to_lowercase();
let find = word.to_lowercase();
if find.is_empty() || lower.len() != text.len() {
// A case change that moves bytes (rare scripts): exact matches only.
return text.replace(word, with);
}
let part = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
let mut out = String::with_capacity(text.len());
let mut at = 0;
while let Some(i) = lower[at..].find(&find).map(|i| i + at) {
let end = i + find.len();
if part(text[..i].chars().next_back()) || part(text[end..].chars().next()) {
out.push_str(&text[at..end]);
} else {
out.push_str(&text[at..i]);
out.push_str(with);
}
at = end;
}
out.push_str(&text[at..]);
out
}
/// The end of the log that fits what mclo.gs keeps.
pub fn fit(text: &str) -> &str {
let mut start = 0;
let lines: Vec<usize> = text.match_indices('\n').map(|(i, _)| i + 1).collect();
if lines.len() > MAX_LINES {
start = lines[lines.len() - MAX_LINES - 1];
}
while text.len() - start > MAX_BYTES {
start = text[start..]
.find('\n')
.map_or(text.len(), |i| start + i + 1);
}
&text[start..]
}
/// A shared log: its page, and what deletes it.
#[derive(Clone, Debug, PartialEq)]
pub struct Shared {
pub url: String,
pub id: String,
pub token: String,
}
/// Uploads `text` (already [`private`]) and returns its link.
pub fn share(text: &str) -> Result<Shared, String> {
let body = serde_json::json!({ "content": fit(text), "source": "Horizon launcher" });
let answer: serde_json::Value = ureq::post(&format!("{}/log", api()))
.send_json(body)
.map_err(|e| format!("mclo.gs didn't take the log: {e}"))?
.into_json()
.map_err(|e| format!("mclo.gs answered something unexpected: {e}"))?;
let field = |k: &str| answer[k].as_str().map(str::to_string);
match (
answer["success"].as_bool(),
field("url"),
field("id"),
field("token"),
) {
(Some(true), Some(url), Some(id), Some(token)) => Ok(Shared { url, id, token }),
_ => Err(format!(
"mclo.gs didn't take the log: {}",
field("error").unwrap_or_else(|| "no reason given".into())
)),
}
}
/// Deletes a log shared from here.
pub fn unshare(shared: &Shared) -> Result<(), String> {
ureq::delete(&format!("{}/log/{}", api(), shared.id))
.set("Authorization", &format!("Bearer {}", shared.token))
.call()
.map_err(|e| format!("mclo.gs didn't delete it: {e}"))?;
Ok(())
}
#[cfg(test)]
#[path = "game_log/tests.rs"]
mod tests;
+114
View File
@@ -0,0 +1,114 @@
use super::*;
use std::io::{BufRead, BufReader, Read, Write};
#[test]
fn search_matches_every_word_in_any_case() {
assert!(matches(
"[12:00:01] [Render thread/WARN]: Missing texture",
"warn texture"
));
assert!(!matches("[12:00:01] [Render thread/INFO]: Loaded", "warn"));
assert!(matches("anything", " "));
}
#[test]
fn the_shared_log_has_no_home_folder_or_names() {
let log = "Loading /Users/ludovicmarie/Library/Application Support/horizon/options.txt\n\
C:\\Users\\ludovicmarie\\AppData\\horizon too\n\
Ludo47 joined the game\n\
<ludo47> hi, Ludo470 is someone else\n\
user LUDOVICMARIE on ludovicmarie-mac\n";
let names = vec!["ludovicmarie".to_string(), "Ludo47".to_string()];
let out = private(log, Some(Path::new("/Users/ludovicmarie")), &names);
assert_eq!(
out,
"Loading ~/Library/Application Support/horizon/options.txt\n\
C:~\\AppData\\horizon too\n\
<player> joined the game\n\
<<player>> hi, Ludo470 is someone else\n\
user <player> on <player>-mac\n"
);
}
#[test]
fn a_long_log_sends_its_end() {
let log: String = (0..30_000).map(|i| format!("line {i}\n")).collect();
let sent = fit(&log);
assert_eq!(sent.lines().count(), MAX_LINES);
assert!(sent.starts_with("line 5000\n") && sent.ends_with("line 29999\n"));
assert_eq!(fit("short\n"), "short\n");
}
/// A one-request HTTP server that answers `body` and hands back what it was sent.
fn stub(body: &'static str) -> (String, std::thread::JoinHandle<String>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let served = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream);
let mut head = String::new();
let mut length = 0;
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if let Some(n) = line.to_ascii_lowercase().strip_prefix("content-length:") {
length = n.trim().parse().unwrap();
}
head.push_str(&line);
if line == "\r\n" {
break;
}
}
let mut sent = vec![0; length];
reader.read_exact(&mut sent).unwrap();
let reply = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
reader.get_mut().write_all(reply.as_bytes()).unwrap();
head + &String::from_utf8(sent).unwrap()
});
(url, served)
}
#[test]
fn shares_and_deletes_through_the_paste_api() {
let (url, served) =
stub(r#"{"success":true,"id":"Ab12","url":"https://mclo.gs/Ab12","token":"t0k"}"#);
std::env::set_var(PASTE_ENV, &url);
let shared = share("[main/INFO]: hello\n").unwrap();
assert_eq!(
shared,
Shared {
url: "https://mclo.gs/Ab12".into(),
id: "Ab12".into(),
token: "t0k".into()
}
);
let request = served.join().unwrap();
assert!(request.starts_with("POST /log "), "{request}");
assert!(
request.ends_with(r#"{"content":"[main/INFO]: hello\n","source":"Horizon launcher"}"#),
"{request}"
);
let (url, served) = stub(r#"{"success":true}"#);
std::env::set_var(PASTE_ENV, &url);
unshare(&shared).unwrap();
let request = served.join().unwrap();
assert!(request.starts_with("DELETE /log/Ab12 "), "{request}");
assert!(
request
.to_ascii_lowercase()
.contains("authorization: bearer t0k"),
"{request}"
);
let (url, _served) = stub(r#"{"success":false,"error":"Required field 'content' not found."}"#);
std::env::set_var(PASTE_ENV, &url);
assert_eq!(
share("").unwrap_err(),
"mclo.gs didn't take the log: Required field 'content' not found."
);
std::env::remove_var(PASTE_ENV);
}
+13 -6
View File
@@ -590,6 +590,7 @@ impl Launcher {
cx.notify();
})),
)
.child(self.shortcut_button(&instance, cx))
.child(
button("instance-folder", "Open folder", Kind::Ghost, &t).on_click(
move |_, _, cx| {
@@ -604,14 +605,20 @@ impl Launcher {
Some(
div()
.flex()
.items_baseline()
.gap(px(10.))
.child(instance.name.clone())
.flex_col()
.child(
text(theme::CAPTION)
.text_color(t.muted)
.child(played_line(&instance)),
div()
.flex()
.items_baseline()
.gap(px(10.))
.child(instance.name.clone())
.child(
text(theme::CAPTION)
.text_color(t.muted)
.child(played_line(&instance)),
),
)
.children(self.shortcut_note(&instance.id))
.into_any_element(),
),
Some(actions.into_any_element()),
+2
View File
@@ -9,6 +9,7 @@ pub mod desktop_seams;
pub mod export;
#[cfg(unix)]
pub mod game_app;
pub mod game_log;
pub mod heads;
pub mod import;
pub mod instances;
@@ -20,6 +21,7 @@ pub mod proxy;
pub mod screenshots;
pub mod server_list;
pub mod servers;
pub mod shortcut;
pub mod signed;
pub mod sources;
pub mod tailscale;
+17
View File
@@ -10,6 +10,7 @@ mod cli;
mod cli_tools;
mod collection;
mod console;
mod console_page;
mod cull;
mod datapacks_page;
mod export_page;
@@ -29,8 +30,10 @@ mod perf;
mod screenshots_page;
mod scrollbar;
mod servers_page;
mod shortcut_page;
mod theme;
mod titlebar;
mod while_playing;
mod worlds_page;
use gpui::{
@@ -245,6 +248,11 @@ struct Launcher {
world_packs: datapacks_page::WorldPacks,
export: Option<export_page::ExportPanel>,
importing: import_page::Importing,
console: console_page::Console,
/// What the window did when the game started (`while_playing.rs`).
aside: while_playing::Aside,
/// The selected instance's desktop shortcut (`shortcut_page.rs`).
shortcuts: shortcut_page::Shortcuts,
/// The name the game made up for an offline player (`horizon-player.txt`), for the top bar.
offline_name: Option<String>,
/// The player's servers, for the sidebar until the Servers page has read them.
@@ -411,6 +419,9 @@ impl Launcher {
world_packs: datapacks_page::WorldPacks::default(),
export: None,
importing: Default::default(),
console: Default::default(),
aside: while_playing::Aside::new(window.window_handle()),
shortcuts: Default::default(),
offline_name: offline_name(),
side_servers: horizon_launcher::servers::list(),
art: art::Art::default(),
@@ -603,6 +614,7 @@ impl Launcher {
Ok(r) => {
self.client = Some(r);
self.client_run = run.map(|id| (id, Instant::now()));
self.step_aside(cx);
}
Err(e) => self.client_error = Some(e),
}
@@ -720,6 +732,7 @@ impl Launcher {
}
Job::Client => {
self.client = None;
self.come_back(cx);
// The game saved: last played, sizes and pictures changed.
self.worlds.stale();
self.multiplayer.stale();
@@ -2229,6 +2242,9 @@ impl Launcher {
page = page.child(notice(e, &t));
}
page = page.child(rows);
if let Some(console) = self.console_section(cx) {
page = page.child(console);
}
if let Some(worlds) = self.worlds_section(cx) {
page = page.child(worlds);
}
@@ -2656,6 +2672,7 @@ impl Launcher {
Some(appearance.into_any_element()),
&t,
))
.child(self.while_playing_row(n + 3, cx))
.child(index_row(
n + 3,
"Show the opening",
+5 -2
View File
@@ -38,10 +38,12 @@ pub(super) enum Field {
/// Home's Multiplayer section's (`multiplayer_page.rs`).
ServerName,
ServerAddress,
/// Home's Console's search (`console_page.rs`).
ConsoleSearch,
}
impl Field {
const ALL: [Field; 13] = [
const ALL: [Field; 14] = [
Field::Name,
Field::Seed,
Field::Players,
@@ -55,6 +57,7 @@ impl Field {
Field::InstanceGroup,
Field::ServerName,
Field::ServerAddress,
Field::ConsoleSearch,
];
}
@@ -1031,7 +1034,7 @@ impl Launcher {
Field::Port => self.save_port(),
Field::Token => self.save_token(),
Field::Email => self.invite_friend(),
Field::InstanceName | Field::InstanceGroup => {}
Field::InstanceName | Field::InstanceGroup | Field::ConsoleSearch => {}
Field::ServerName | Field::ServerAddress => self.add_server(),
}
}
+427
View File
@@ -0,0 +1,427 @@
//! A shortcut on the desktop that plays one instance: it runs
//! `horizon-launcher play --instance <id>`, so the game starts without the launcher's window.
//!
//! - Linux: `<name> (Horizon).desktop` on the desktop (executable, and marked trusted with `gio`
//! where it exists, so GNOME starts it), and the same entry in the applications menu
//! (`applications/horizon-instance-<id>.desktop`, `desktop::data_home`).
//! - macOS: `<name> (Horizon).app`, a small app whose program is a shell script, with Horizon's
//! icon. It has no Dock icon of its own (`LSUIElement`): the game's window is the one to see.
//! - Windows: `<name> (Horizon).lnk`, a Shell Link as Microsoft's published [MS-SHLLINK] format
//! lays it out, to the launcher's `.exe` with Horizon's icon.
//!
//! Each shortcut names its instance's id inside it, so removing finds it after a rename.
//! `HORIZON_LAUNCHER_DESKTOP` points the desktop folder elsewhere, for tests.
//!
//! [MS-SHLLINK]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-shllink/
use crate::desktop;
use crate::desktop_seams;
use std::fs;
use std::path::{Path, PathBuf};
pub const DESKTOP_ENV: &str = "HORIZON_LAUNCHER_DESKTOP";
const MAC_ICON: &[u8] = include_bytes!("../../packaging/icons/horizon.icns");
/// The key a Linux shortcut names its instance with.
const INSTANCE_KEY: &str = "X-Horizon-Instance";
/// And a macOS one, in its Info.plist.
const MAC_KEY: &str = "<key>HorizonInstance</key>";
/// The kinds of shortcut, one per system: all of them can be written and tested anywhere.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum System {
Linux,
Mac,
Windows,
}
impl System {
pub fn here() -> Self {
if cfg!(target_os = "macos") {
Self::Mac
} else if cfg!(windows) {
Self::Windows
} else {
Self::Linux
}
}
fn extension(self) -> &'static str {
match self {
Self::Linux => "desktop",
Self::Mac => "app",
Self::Windows => "lnk",
}
}
}
/// The desktop folder: `HORIZON_LAUNCHER_DESKTOP`, else the system's.
pub fn desktop_dir() -> Option<PathBuf> {
std::env::var_os(DESKTOP_ENV)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.or_else(dirs::desktop_dir)
}
/// The shortcut's file name: the instance's name without the characters a file name can't
/// hold on any of the three systems.
pub fn file_name(name: &str, system: System) -> String {
let clean: String = name
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
c if c.is_control() => '-',
c => c,
})
.collect();
let clean = clean.trim().trim_matches('.');
let clean = if clean.is_empty() { "Instance" } else { clean };
format!("{clean} (Horizon).{}", system.extension())
}
/// The id as a file name or bundle id part: letters, digits and `-`.
fn slug(id: &str) -> String {
id.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect()
}
/// A Linux `.desktop` entry that plays the instance.
pub fn desktop_entry(exe: &Path, id: &str, name: &str) -> String {
let field = |s: &str| s.replace(['\n', '\r'], " ");
format!(
"[Desktop Entry]\n\
Type=Application\n\
Name={name}\n\
Comment=Play {name} in Horizon\n\
Exec={} play --instance {}\n\
TryExec={}\n\
Icon=horizon\n\
Terminal=false\n\
Categories=Game;\n\
StartupWMClass=horizon\n\
{INSTANCE_KEY}={id}\n",
desktop::quote(exe),
desktop::quote(Path::new(id)),
exe.display(),
name = field(name),
id = field(id),
)
}
/// `'text'` for a POSIX shell.
fn sh_quote(text: &str) -> String {
format!("'{}'", text.replace('\'', r"'\''"))
}
fn xml(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// A macOS app's files, by path inside the `.app`, and whether each is a program.
pub fn mac_app(exe: &Path, id: &str, name: &str) -> Vec<(&'static str, Vec<u8>, bool)> {
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>play</string>
<key>CFBundleIdentifier</key>
<string>dev.horizon.instance.{}</string>
<key>CFBundleName</key>
<string>{}</string>
<key>CFBundleIconFile</key>
<string>horizon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSUIElement</key>
<true/>
{MAC_KEY}
<string>{}</string>
</dict>
</plist>
"#,
slug(id),
xml(name),
xml(id),
);
let script = format!(
"#!/bin/sh\n# Plays the Horizon instance {}.\nexec {} play --instance {}\n",
name.replace('\n', " "),
sh_quote(&exe.to_string_lossy()),
sh_quote(id),
);
vec![
("Contents/Info.plist", plist.into_bytes(), false),
("Contents/MacOS/play", script.into_bytes(), true),
("Contents/Resources/horizon.icns", MAC_ICON.to_vec(), false),
]
}
/// The arguments of a Windows shortcut: the id in quotes, with its own quotes escaped.
fn windows_args(id: &str) -> String {
format!("play --instance \"{}\"", id.replace('"', "\\\""))
}
fn utf16(text: &str) -> Vec<u8> {
text.encode_utf16().flat_map(u16::to_le_bytes).collect()
}
/// A Windows Shell Link ([MS-SHLLINK]) to `exe` (a Windows path) with `args`: the header, a
/// LinkInfo with the local path (ANSI and Unicode), then the name, working folder, arguments and
/// icon as Unicode strings.
pub fn lnk(exe: &str, args: &str, comment: &str) -> Vec<u8> {
const HAS_LINK_INFO: u32 = 0x02;
const HAS_NAME: u32 = 0x04;
const HAS_WORKING_DIR: u32 = 0x10;
const HAS_ARGUMENTS: u32 = 0x20;
const HAS_ICON_LOCATION: u32 = 0x40;
const IS_UNICODE: u32 = 0x80;
const FILE_ATTRIBUTE_NORMAL: u32 = 0x80;
const SW_SHOWNORMAL: u32 = 1;
let flags =
HAS_LINK_INFO | HAS_NAME | HAS_WORKING_DIR | HAS_ARGUMENTS | HAS_ICON_LOCATION | IS_UNICODE;
let mut out = Vec::new();
out.extend(0x4Cu32.to_le_bytes());
// CLSID 00021401-0000-0000-C000-000000000046.
out.extend([
0x01, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x46,
]);
out.extend(flags.to_le_bytes());
out.extend(FILE_ATTRIBUTE_NORMAL.to_le_bytes());
out.extend([0; 24]); // creation, access and write times: unknown
out.extend(0u32.to_le_bytes()); // file size
out.extend(0i32.to_le_bytes()); // icon index
out.extend(SW_SHOWNORMAL.to_le_bytes());
out.extend([0; 2 + 2 + 4 + 4]); // hot key, reserved
// LinkInfo: a header with the Unicode offsets (0x24 bytes), a VolumeID for a fixed drive
// with no label, the path in ANSI (anything else as '?') and in UTF-16, and empty suffixes.
let header = 0x24u32;
let volume: Vec<u8> = [0x11u32, 3, 0, 0x10]
.iter()
.flat_map(|v| v.to_le_bytes())
.chain([0])
.collect();
let ansi: Vec<u8> = exe
.chars()
.map(|c| if c.is_ascii() { c as u8 } else { b'?' })
.chain([0])
.collect();
let wide: Vec<u8> = utf16(exe).into_iter().chain([0, 0]).collect();
let volume_at = header;
let path_at = volume_at + volume.len() as u32;
let suffix_at = path_at + ansi.len() as u32;
let wide_at = suffix_at + 1;
let wide_suffix_at = wide_at + wide.len() as u32;
let size = wide_suffix_at + 2;
for v in [
size,
header,
1, // VolumeIDAndLocalBasePath
volume_at,
path_at,
0, // no network path
suffix_at,
wide_at,
wide_suffix_at,
] {
out.extend(v.to_le_bytes());
}
out.extend(volume);
out.extend(ansi);
out.push(0);
out.extend(wide);
out.extend([0, 0]);
let folder = exe.rsplit_once('\\').map_or("", |(dir, _)| dir);
for text in [comment, folder, args, exe] {
out.extend((text.encode_utf16().count() as u16).to_le_bytes());
out.extend(utf16(text));
}
out.extend(0u32.to_le_bytes()); // no extra data
out
}
/// Whether the shortcut at `path` plays the instance `id`.
fn plays(path: &Path, id: &str) -> bool {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match ext {
"desktop" => fs::read_to_string(path)
.is_ok_and(|t| t.lines().any(|l| l == format!("{INSTANCE_KEY}={id}"))),
"app" => fs::read_to_string(path.join("Contents/Info.plist"))
.is_ok_and(|t| t.contains(&format!("{MAC_KEY}\n\t<string>{}</string>", xml(id)))),
"lnk" => fs::read(path).is_ok_and(|b| {
let want = utf16(&windows_args(id));
b.windows(want.len()).any(|w| w == want)
}),
_ => false,
}
}
/// Writes `bytes` beside `path` and renames it in, so nothing reads half a file.
fn write(path: &Path, bytes: &[u8], program: bool) -> Result<(), String> {
let fail = |e: std::io::Error| format!("{}: {e}", path.display());
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).map_err(fail)?;
}
let temp = path.with_extension("horizon-tmp");
fs::write(&temp, bytes).map_err(fail)?;
#[cfg(unix)]
if program {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&temp, fs::Permissions::from_mode(0o755)).map_err(fail)?;
}
#[cfg(not(unix))]
let _ = program;
fs::rename(&temp, path).map_err(fail)
}
/// Moves a file or folder out of the way, where the player can get it back.
pub type Trash<'a> = &'a dyn Fn(&Path) -> Result<(), String>;
/// Makes the shortcut for `system` in `desktop` (and, for Linux, the menu entry under
/// `menu`, an `applications` folder). The instance's shortcut of the same name is rewritten;
/// one under an older name goes to `trash`. Returns what it wrote.
pub fn make_in(
system: System,
desktop: &Path,
menu: Option<&Path>,
exe: &Path,
id: &str,
name: &str,
trash: Trash,
) -> Result<Vec<PathBuf>, String> {
let path = desktop.join(file_name(name, system));
let item = menu.map(|m| m.join(format!("horizon-instance-{}.desktop", slug(id))));
for old in find_in(desktop, menu, id) {
if old != path && Some(&old) != item.as_ref() {
trash(&old)?;
}
}
if path.exists() && !plays(&path, id) {
return Err(format!(
"{} is already on the desktop, and isn't this instance's shortcut.",
path.file_name().unwrap().to_string_lossy()
));
}
let mut made = vec![path.clone()];
match system {
System::Linux => {
let entry = desktop_entry(exe, id, name);
write(&path, entry.as_bytes(), true)?;
if let Some(item) = item {
write(&item, entry.as_bytes(), false)?;
made.push(item);
}
}
System::Mac => {
for (inside, bytes, program) in mac_app(exe, id, name) {
write(&path.join(inside), &bytes, program)?;
}
}
System::Windows => write(
&path,
&lnk(
&exe.to_string_lossy(),
&windows_args(id),
&format!("Play {name} in Horizon"),
),
false,
)?,
}
Ok(made)
}
/// The instance's shortcuts in `desktop` and the menu.
pub fn find_in(desktop: &Path, menu: Option<&Path>, id: &str) -> Vec<PathBuf> {
let mut found: Vec<PathBuf> = fs::read_dir(desktop)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| plays(p, id))
.collect();
if let Some(item) = menu
.map(|m| m.join(format!("horizon-instance-{}.desktop", slug(id))))
.filter(|p| plays(p, id))
{
found.push(item);
}
found.sort();
found
}
/// Moves the instance's shortcuts to `trash`. Returns what it moved.
pub fn remove_in(
desktop: &Path,
menu: Option<&Path>,
id: &str,
trash: Trash,
) -> Result<Vec<PathBuf>, String> {
let found = find_in(desktop, menu, id);
for path in &found {
trash(path)?;
}
Ok(found)
}
fn menu() -> Option<PathBuf> {
(System::here() == System::Linux)
.then(desktop::data_home)
.flatten()
.map(|d| d.join("applications"))
}
fn desktop_or_err() -> Result<PathBuf, String> {
desktop_dir().ok_or_else(|| "This computer has no desktop folder.".into())
}
/// Makes the shortcut on this computer's desktop that plays `id`, starting `exe`.
pub fn make(exe: &Path, id: &str, name: &str) -> Result<Vec<PathBuf>, String> {
let desktop = desktop_or_err()?;
let made = make_in(
System::here(),
&desktop,
menu().as_deref(),
&desktop::target(exe),
id,
name,
&desktop_seams::to_trash,
)?;
if System::here() == System::Linux {
// GNOME starts a desktop file only once it is trusted; elsewhere this does nothing.
let _ = std::process::Command::new("gio")
.args(["set", "-t", "string"])
.arg(&made[0])
.args(["metadata::trusted", "true"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
Ok(made)
}
/// The instance's shortcuts on this computer.
pub fn find(id: &str) -> Vec<PathBuf> {
desktop_dir()
.map(|d| find_in(&d, menu().as_deref(), id))
.unwrap_or_default()
}
/// Moves the instance's shortcuts on this computer to the trash.
pub fn remove(id: &str) -> Result<Vec<PathBuf>, String> {
remove_in(
&desktop_or_err()?,
menu().as_deref(),
id,
&desktop_seams::to_trash,
)
}
#[cfg(test)]
#[path = "shortcut/tests.rs"]
mod tests;
+197
View File
@@ -0,0 +1,197 @@
use super::*;
fn scratch(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("horizon-shortcut-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
root
}
#[test]
fn names_fit_every_system() {
assert_eq!(
file_name("Hardcore", System::Linux),
"Hardcore (Horizon).desktop"
);
assert_eq!(
file_name("A/B: C?", System::Windows),
"A-B- C- (Horizon).lnk"
);
assert_eq!(file_name(" .. ", System::Mac), "Instance (Horizon).app");
assert_eq!(slug("port:all the mods"), "port-all-the-mods");
}
#[test]
fn a_linux_entry_plays_its_instance() {
let entry = desktop_entry(
Path::new("/opt/My Horizon/horizon-launcher"),
"port:atm",
"ATM",
);
assert!(entry.starts_with("[Desktop Entry]\nType=Application\nName=ATM\n"));
assert!(entry
.contains("\nExec=\"/opt/My Horizon/horizon-launcher\" play --instance \"port:atm\"\n"));
assert!(entry.contains("\nTryExec=/opt/My Horizon/horizon-launcher\n"));
assert!(entry.ends_with("\nX-Horizon-Instance=port:atm\n"));
let odd = desktop_entry(Path::new("/x"), "a", "Two\nlines");
assert!(odd.contains("Name=Two lines\n"));
}
#[test]
fn a_mac_app_runs_a_script_that_plays_its_instance() {
let files = mac_app(
Path::new("/Applications/Horizon Launcher.app/Contents/MacOS/horizon-launcher"),
"it's",
"Tom & Jerry",
);
let (_, plist, _) = &files[0];
let plist = String::from_utf8(plist.clone()).unwrap();
assert!(plist.contains("<string>Tom &amp; Jerry</string>"));
assert!(plist.contains("<string>dev.horizon.instance.it-s</string>"));
assert!(plist.contains("<key>LSUIElement</key>\n\t<true/>"));
let (path, script, program) = &files[1];
assert_eq!((*path, *program), ("Contents/MacOS/play", true));
assert!(String::from_utf8(script.clone()).unwrap().ends_with(
"exec '/Applications/Horizon Launcher.app/Contents/MacOS/horizon-launcher' play \
--instance 'it'\\''s'\n"
));
assert_eq!(files[2].1, MAC_ICON);
}
/// Reads a Shell Link back by its spec: the header, the LinkInfo's paths, and the strings.
fn read_lnk(b: &[u8]) -> (u32, String, String, Vec<String>) {
let u32_at = |at: usize| u32::from_le_bytes(b[at..at + 4].try_into().unwrap());
assert_eq!(u32_at(0), 0x4C);
assert_eq!(
&b[4..20],
&[1, 0x14, 2, 0, 0, 0, 0, 0, 0xC0, 0, 0, 0, 0, 0, 0, 0x46]
);
let flags = u32_at(20);
assert_eq!(u32_at(60), 1, "show normal");
let info = 76;
let size = u32_at(info) as usize;
assert_eq!(u32_at(info + 4), 0x24);
assert_eq!(u32_at(info + 8), 1);
let volume = info + u32_at(info + 12) as usize;
assert_eq!((u32_at(volume), u32_at(volume + 4)), (0x11, 3));
let cstr = |at: usize| {
let end = b[at..].iter().position(|&c| c == 0).unwrap();
String::from_utf8(b[at..at + end].to_vec()).unwrap()
};
let ansi = cstr(info + u32_at(info + 16) as usize);
assert_eq!(cstr(info + u32_at(info + 24) as usize), "");
let wide_at = info + u32_at(info + 28) as usize;
let wide: Vec<u16> = b[wide_at..]
.chunks(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.take_while(|&c| c != 0)
.collect();
let wide = String::from_utf16(&wide).unwrap();
assert_eq!(
ansi.replace('?', ""),
wide.replace(|c: char| !c.is_ascii(), "")
);
let mut at = info + size;
let mut strings = Vec::new();
for _ in 0..4 {
let n = u16::from_le_bytes([b[at], b[at + 1]]) as usize;
let units: Vec<u16> = b[at + 2..at + 2 + 2 * n]
.chunks(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
strings.push(String::from_utf16(&units).unwrap());
at += 2 + 2 * n;
}
assert_eq!(&b[at..], &[0, 0, 0, 0], "the terminal block ends it");
(flags, ansi, wide, strings)
}
#[test]
fn a_windows_shortcut_is_a_shell_link_to_the_launcher() {
let exe = r"C:\Users\Zoé\AppData\Local\Horizon\horizon-launcher.exe";
let b = lnk(exe, &windows_args("port:atm"), "Play ATM in Horizon");
let (flags, ansi, wide, strings) = read_lnk(&b);
assert_eq!(flags, 0x02 | 0x04 | 0x10 | 0x20 | 0x40 | 0x80);
assert_eq!(
ansi,
r"C:\Users\Zo?\AppData\Local\Horizon\horizon-launcher.exe"
);
assert_eq!(wide, exe);
assert_eq!(
strings,
[
"Play ATM in Horizon",
r"C:\Users\Zoé\AppData\Local\Horizon",
"play --instance \"port:atm\"",
exe,
]
);
}
#[test]
fn shortcuts_are_made_found_renamed_and_removed() {
let root = scratch("make");
let (desktop, menu, trash) = (
root.join("Desktop"),
root.join("applications"),
root.join("trash"),
);
let bin = trash.clone();
let trash_fn = move |p: &Path| desktop_seams::to_folder(p, &bin);
let t: Trash = &trash_fn;
let exe = Path::new("/opt/horizon-launcher");
for system in [System::Linux, System::Mac, System::Windows] {
let made = make_in(
system,
&desktop,
Some(&menu),
exe,
"hardcore",
"Hardcore",
t,
)
.unwrap();
assert_eq!(made[0], desktop.join(file_name("Hardcore", system)));
assert_eq!(find_in(&desktop, Some(&menu), "hardcore"), {
let mut m = made.clone();
m.sort();
m
});
// Again: rewritten in place, nothing in the trash.
make_in(
system,
&desktop,
Some(&menu),
exe,
"hardcore",
"Hardcore",
t,
)
.unwrap();
assert!(!trash.exists());
// Renamed: the old one goes, the new one is there.
let made = make_in(system, &desktop, Some(&menu), exe, "hardcore", "Hard", t).unwrap();
assert!(made[0].ends_with(file_name("Hard", system)));
assert!(!desktop.join(file_name("Hardcore", system)).exists());
assert!(find_in(&desktop, Some(&menu), "skyblock").is_empty());
let gone = remove_in(&desktop, Some(&menu), "hardcore", t).unwrap();
assert_eq!(gone.len(), made.len());
assert!(find_in(&desktop, Some(&menu), "hardcore").is_empty());
fs::remove_dir_all(&trash).unwrap();
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let made = make_in(System::Mac, &desktop, None, exe, "a", "A", t).unwrap();
let mode = fs::metadata(made[0].join("Contents/MacOS/play"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111);
}
// Someone else's file of that name stays.
fs::write(desktop.join("Mine (Horizon).desktop"), "not ours").unwrap();
let err = make_in(System::Linux, &desktop, None, exe, "mine", "Mine", t).unwrap_err();
assert!(err.contains("isn't this instance's shortcut"), "{err}");
fs::remove_dir_all(&root).unwrap();
}
+69
View File
@@ -0,0 +1,69 @@
//! The Instance row's desktop shortcut (`horizon_launcher::shortcut`): make one that plays the
//! selected instance, or take it away.
use super::*;
use horizon_launcher::shortcut;
#[derive(Default)]
pub(super) struct Shortcuts {
/// The instance last looked at, and whether it has a shortcut: read again only when the
/// selection changes or a button is pressed, not on every frame.
seen: Option<(String, bool)>,
/// What the last press did, for that instance.
note: Option<(String, Result<String, String>)>,
}
impl Launcher {
fn has_shortcut(&mut self, id: &str) -> bool {
match &self.shortcuts.seen {
Some((seen, has)) if seen == id => *has,
_ => {
let has = !shortcut::find(id).is_empty();
self.shortcuts.seen = Some((id.to_string(), has));
has
}
}
}
fn toggle_shortcut(&mut self, id: String, name: String) {
let has = self.has_shortcut(&id);
let done = match has {
false => shortcut::make(backend::exe_path(), &id, &name).map(|made| {
let file = made[0].file_name().unwrap_or_default().to_string_lossy();
format!("{file} is on your desktop: it plays {name}.")
}),
true => shortcut::remove(&id).map(|_| format!("{name}'s shortcut is in the trash.")),
};
self.shortcuts.seen = None;
self.shortcuts.note = Some((id, done));
}
/// The row's button.
pub(super) fn shortcut_button(
&mut self,
instance: &instances::Instance,
cx: &mut Context<Self>,
) -> Stateful<Div> {
let t = self.theme;
let label = match self.has_shortcut(&instance.id) {
true => "Remove shortcut",
false => "Shortcut",
};
let (id, name) = (instance.id.clone(), instance.name.clone());
button("instance-shortcut", label, Kind::Ghost, &t).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.toggle_shortcut(id.clone(), name.clone());
cx.notify();
},
))
}
/// What the last press did, under the row, while that instance is selected.
pub(super) fn shortcut_note(&self, id: &str) -> Option<Div> {
let t = self.theme;
let (_, said) = self.shortcuts.note.as_ref().filter(|(n, _)| n == id)?;
Some(match said {
Ok(said) => text(theme::CAPTION).text_color(t.muted).child(said.clone()),
Err(e) => text(theme::CAPTION).text_color(t.danger).child(e.clone()),
})
}
}
+92
View File
@@ -0,0 +1,92 @@
//! What the window does while the game runs (`backend::WhilePlaying`): stay, minimise, or hide
//! (macOS), and come back to the front when the game closes, with how the run ended.
use super::*;
use backend::WhilePlaying;
use gpui::AnyWindowHandle;
pub(super) struct Aside {
/// The launcher's window.
window: AnyWindowHandle,
/// What the window did when the game started, to undo when it closes.
stepped: Option<WhilePlaying>,
}
impl Aside {
pub(super) fn new(window: AnyWindowHandle) -> Self {
Aside {
window,
stepped: None,
}
}
}
impl Launcher {
/// The game started: get out of its way as the player asked. Read from `settings.json`, so
/// `horizon-launcher settings while-playing …` counts at once.
pub(super) fn step_aside(&mut self, cx: &mut Context<Self>) {
self.settings.while_playing = Settings::load().while_playing;
let how = self.settings.while_playing.here();
if how == WhilePlaying::Stay {
return;
}
let window = self.aside.window;
self.aside.stepped = Some(how);
cx.defer(move |cx| match how {
WhilePlaying::Hide => cx.hide(),
_ => {
let _ = window.update(cx, |_, window, _| window.minimize_window());
}
});
}
/// The game closed: back to the front, where its play time, errors and log are.
pub(super) fn come_back(&mut self, cx: &mut Context<Self>) {
if self.aside.stepped.take().is_none() {
return;
}
let window = self.aside.window;
cx.defer(move |cx| {
cx.activate(true);
let _ = window.update(cx, |_, window, _| window.activate_window());
});
}
/// Settings' row for it.
pub(super) fn while_playing_row(&self, n: usize, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let mut options = vec![
(WhilePlaying::Stay, "Stay"),
(WhilePlaying::Minimize, "Minimise"),
];
if cfg!(target_os = "macos") {
options.push((WhilePlaying::Hide, "Hide"));
}
let current = self.settings.while_playing.here();
let picker = segments("while-playing", &options, current, &t, {
let this = cx.entity().downgrade();
move |choice, _, cx| {
let _ = this.update(cx, |this, cx| {
this.settings.while_playing = choice;
this.settings.save();
cx.notify();
});
}
});
let said = match current {
WhilePlaying::Stay => "The launcher stays open while you play.",
WhilePlaying::Minimize => {
"The launcher goes down when the game starts, and comes back when it closes."
}
WhilePlaying::Hide => {
"The launcher hides when the game starts, and comes back when it closes."
}
};
index_row(
n,
"While playing",
Some(div().child(said).into_any_element()),
Some(picker.into_any_element()),
&t,
)
}
}
+1
View File
@@ -90,6 +90,7 @@ impl Game {
fn predict_break(&mut self, pos: [i32; 3], _: u32, _: i32) { self.world.broken.push(pos); }
fn send(&mut self, S::PlayerAction(packet): S) { self.packets.push(packet); }
fn crack_particle(&mut self, _: [i32; 3], _: u8, _: u32) {}
fn breaking_block_effects(&mut self, _: [i32; 3], _: u8, _: bool) {}
METHOD
}
fn main() {