mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ad735b9d7 | ||
|
|
6f19f06634 | ||
|
|
89225ec081 | ||
|
|
6044c6ff88 | ||
|
|
d74d39cb05 | ||
|
|
73aef0ce8e | ||
|
|
82c8398c6b | ||
|
|
cde33a8e42 | ||
|
|
0ccf3e110b | ||
|
|
0c127ec1d9 | ||
|
|
7f5e3384b3 | ||
|
|
6f4c2cfbbc |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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..)
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -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 & 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();
|
||||
}
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user