Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ thiserror.workspace = true
toml.workspace = true
image.workspace = true
tray-icon = { version = "0.26.1", default-features = false }
auto-launch = "0.6.0"
raw-window-handle = "0.6.2"
async-channel = "2.5.0"
rust-i18n = "4.2.4"
Expand All @@ -47,11 +46,16 @@ tracing-subscriber = { version = "0.3.23", default-features = false, features =
"time",
] }

[target.'cfg(not(target_os = "linux"))'.dependencies]
auto-launch = "0.6.0"

[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.5"

[target.'cfg(target_os = "windows")'.dependencies]
clipboard-win = "5.4.1"
windows-registry = "0.6.1"
windows-result = "0.4.1"
windows-sys = { version = "0.61.2", features = [
"Win32_Graphics_Gdi",
"Win32_System_Threading",
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,11 @@ still blocks launch, review and allow Ropy in System Settings → Privacy & Secu
After downloading the `.dmg` file and dragging Ropy.app to the Applications folder, you may need to remove the quarantine attribute to run the application without issues. Open Terminal and run the following commands:

```sh
xattr -rc /Applications/Ropy.app
sudo xattr -r -d com.apple.quarantine /Applications/Ropy.app
xattr -r -d com.apple.quarantine /Applications/Ropy.app
```

If the command reports a permission error, repeat it with `sudo`.

### Windows

You can install Ropy using [Scoop](https://scoop.sh/):
Expand Down
5 changes: 3 additions & 2 deletions docs/README/README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@ Homebrew 管理。当前发布尚未签名,自建 Cask 会在安装后自动
下载 `.dmg` 文件并将 Ropy.app 拖到应用程序文件夹后,您可能需要移除隔离属性才能正常运行应用程序。打开终端并运行以下命令:

```sh
xattr -rc /Applications/Ropy.app
sudo xattr -r -d com.apple.quarantine /Applications/Ropy.app
xattr -r -d com.apple.quarantine /Applications/Ropy.app
```

如果命令提示权限不足,在同一命令前加上 `sudo` 后重试。

### Windows

您可以使用 [Scoop](https://scoop.sh/) 安装 Ropy:
Expand Down
6 changes: 1 addition & 5 deletions docs/WAYLAND.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,7 @@ focus for its own process after the popup maps. No configuration-file edits or
extra activation process are needed. Changing or clearing the shortcut updates
the runtime binding; configuration reloads restore it while Ropy is running.

The adapter refuses conflicting bindings instead of replacing them. Save failures
retain the previous shortcut. Letters, digits, F1–F24, navigation keys and common
punctuation are supported; unsupported keys fail registration. Physical-keycode
bindings with the same modifiers are conservatively treated as conflicts. Remove
previous manual Ropy bindings before assigning the same chord in Ropy.
The adapter refuses confirmed symbol-key and catch-all conflicts instead of replacing existing bindings. Save failures retain the previous shortcut. Letters, digits, F1–F24, navigation keys and common punctuation are supported; unsupported keys fail registration. Hyprland’s binding list does not include the keymap needed to resolve numeric physical keycodes, so a physical-keycode binding alone is not treated as a confirmed conflict. Check those bindings manually and remove previous manual Ropy bindings before assigning the same chord in Ropy.

On shutdown, Ropy disables only its own Lua binding handle. Disabled handles are
reused on later registration and removed by Hyprland's next configuration reload.
Expand Down
8 changes: 6 additions & 2 deletions scripts/init.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
#!/usr/bin/env bash
set -e
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
DIST_VERSION="$(python3 -c 'import tomllib; print(tomllib.load(open("dist-workspace.toml", "rb"))["dist"]["cargo-dist-version"])')"

# Parser hooks use uv's isolated Python tool environments.
if ! command -v uvx &>/dev/null; then
Expand All @@ -11,7 +15,7 @@ fi
cargo install cargo-binstall

# Other dependencies
cargo binstall 'hk@^2.5' cargo-dist cargo-release git-cliff cargo-outdated
cargo binstall 'hk@^2.5' "cargo-dist@$DIST_VERSION" cargo-release git-cliff cargo-outdated

# For pre-commit hooks
# Replace the previous hook script, including on Git with config-based hooks.
Expand Down
49 changes: 49 additions & 0 deletions scripts/tests/test_init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Check setup tool selection without installing tools or Git hooks."""

import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest


ROOT = Path(__file__).resolve().parents[2]


class SetupTests(unittest.TestCase):
def test_setup_installs_configured_dist_from_its_repository(self):
with tempfile.TemporaryDirectory() as directory:
work = Path(directory).resolve()
repository = work / "repository"
scripts = repository / "scripts"
scripts.mkdir(parents=True)
shutil.copyfile(ROOT / "scripts/init.sh", scripts / "init.sh")
commands = work / "bin"
commands.mkdir()
log = work / "commands"
for name, body in {
"cargo": 'printf "%s\\n" "$*" >> "$INIT_TEST_LOG"',
"hk": 'printf "hooks in %s\\n" "$PWD" >> "$INIT_TEST_LOG"',
"uvx": "exit 0",
}.items():
command = commands / name
command.write_text("#!/bin/sh\nset -eu\n" + body + "\n")
command.chmod(0o755)
env = dict(os.environ, PATH=f"{commands}:{os.environ['PATH']}", INIT_TEST_LOG=str(log))
for version in ("0.32.0", "9.9.9"):
with self.subTest(version=version):
(repository / "dist-workspace.toml").write_text(
f'[dist]\ncargo-dist-version = "{version}"\n'
)
log.unlink(missing_ok=True)
result = subprocess.run(
["bash", str(scripts / "init.sh")], cwd=work, env=env,
capture_output=True, text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(log.read_text().splitlines(), [
"install cargo-binstall",
f"binstall hk@^2.5 cargo-dist@{version} cargo-release git-cliff cargo-outdated",
f"hooks in {repository}",
])
169 changes: 84 additions & 85 deletions src/config/autostart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ use std::{env, path::Path};
use auto_launch::MacOSLaunchMode;
#[cfg(target_os = "windows")]
use auto_launch::WindowsEnableMode;
#[cfg(not(target_os = "linux"))]
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
use thiserror::Error;

#[cfg(any(target_os = "linux", test))]
mod linux;

#[derive(Debug, Error)]
pub(crate) enum AutoStartError {
#[error("Failed to get executable path: {0}")]
Expand All @@ -22,10 +26,12 @@ pub(crate) enum AutoStartError {
StatusCheck(String),
}

/// Owns the platform `AutoLaunch` handle. The app always boots hidden into
/// the tray, so no extra CLI flag is needed to differentiate a
/// system-triggered launch from a user-triggered one.
/// Manages the platform login registration. The app always boots hidden into
/// the tray, so system-triggered launches need no additional CLI flag.
pub(crate) struct AutoStartManager {
#[cfg(target_os = "linux")]
desktop_entry: linux::DesktopEntry,
#[cfg(not(target_os = "linux"))]
auto_launch: AutoLaunch,
#[cfg(target_os = "macos")]
legacy_launch_agent: AutoLaunch,
Expand All @@ -35,20 +41,38 @@ impl AutoStartManager {
pub(crate) fn new(app_name: &str) -> Result<Self, AutoStartError> {
let app_path = Self::get_app_path()?;

let mut builder = AutoLaunchBuilder::new();
builder.set_app_name(app_name).set_app_path(&app_path);
#[cfg(target_os = "linux")]
let desktop_entry = linux::DesktopEntry::new(
&dirs::config_dir().ok_or_else(|| {
AutoStartError::Initialization("config directory not found".into())
})?,
app_name,
&app_path,
);

#[cfg(target_os = "macos")]
builder.set_macos_launch_mode(MacOSLaunchMode::AppleScript);
#[cfg(not(target_os = "linux"))]
let auto_launch = {
// Windows Run values are command lines, so quote the executable.
#[cfg(target_os = "windows")]
let app_path = format!("\"{app_path}\"");
let mut builder = AutoLaunchBuilder::new();
builder.set_app_name(app_name).set_app_path(&app_path);

#[cfg(target_os = "windows")]
builder.set_windows_enable_mode(WindowsEnableMode::CurrentUser);
#[cfg(target_os = "macos")]
builder.set_macos_launch_mode(MacOSLaunchMode::AppleScript);

let auto_launch = builder.build().map_err(|e| {
AutoStartError::Initialization(format!("Failed to build AutoLaunch: {e}"))
})?;
#[cfg(target_os = "windows")]
builder.set_windows_enable_mode(WindowsEnableMode::CurrentUser);

builder.build().map_err(|e| {
AutoStartError::Initialization(format!("Failed to build AutoLaunch: {e}"))
})?
};

Ok(Self {
#[cfg(target_os = "linux")]
desktop_entry,
#[cfg(not(target_os = "linux"))]
auto_launch,
#[cfg(target_os = "macos")]
legacy_launch_agent: Self::build_macos_legacy_launch_agent(app_name, &app_path)?,
Expand Down Expand Up @@ -100,40 +124,57 @@ impl AutoStartManager {
})
}

pub(crate) fn enable(&self) -> Result<(), AutoStartError> {
self.auto_launch
.enable()
.map_err(|e| AutoStartError::Enable(e.to_string()))
}

pub(crate) fn disable(&self) -> Result<(), AutoStartError> {
self.auto_launch
.disable()
.map_err(|e| AutoStartError::Disable(e.to_string()))
}

pub(crate) fn is_enabled(&self) -> Result<bool, AutoStartError> {
self.auto_launch
.is_enabled()
.map_err(|e| AutoStartError::StatusCheck(e.to_string()))
}

/// Make the system state match the user's preference, but skip the
/// platform call when it already matches — repeatedly toggling
/// Login Items / registry entries is unnecessary churn and (on
/// some Linux desktops) racy.
/// Reconcile the registered target as well as the enabled state. Matching
/// registrations are left intact to avoid unnecessary native writes.
pub(crate) fn sync_state(&self, enabled: bool) -> Result<(), AutoStartError> {
let current_enabled = self.is_enabled()?;
match autostart_sync_action(enabled, current_enabled) {
AutoStartSyncAction::None => Ok(()),
AutoStartSyncAction::Enable => self.enable(),
AutoStartSyncAction::Disable => self.disable(),
}?;
#[cfg(target_os = "linux")]
{
self.desktop_entry.sync_state(enabled)
}
#[cfg(not(target_os = "linux"))]
{
let current_enabled = self
.auto_launch
.is_enabled()
.map_err(|error| AutoStartError::StatusCheck(error.to_string()))?;
#[cfg(target_os = "windows")]
let target_matches = !enabled || !current_enabled || self.windows_target_matches()?;
#[cfg(not(target_os = "windows"))]
let target_matches = true;

if enabled && (!current_enabled || !target_matches) {
self.auto_launch
.enable()
.map_err(|error| AutoStartError::Enable(error.to_string()))?;
} else if !enabled && current_enabled {
self.auto_launch
.disable()
.map_err(|error| AutoStartError::Disable(error.to_string()))?;
}

#[cfg(target_os = "macos")]
self.disable_macos_legacy_launch_agent()?;
#[cfg(target_os = "macos")]
self.disable_macos_legacy_launch_agent()?;
Ok(())
}
}

Ok(())
#[cfg(target_os = "windows")]
fn windows_target_matches(&self) -> Result<bool, AutoStartError> {
let registered = windows_registry::CURRENT_USER
.open(r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run")
.and_then(|key| key.get_string(self.auto_launch.get_app_name()));
match registered {
Ok(command) => Ok(command.trim_end() == self.auto_launch.get_app_path()),
Err(error)
if error.code()
== windows_result::HRESULT::from_win32(
windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND,
) =>
{
Ok(false)
}
Err(error) => Err(AutoStartError::StatusCheck(error.to_string())),
}
}

#[cfg(target_os = "macos")]
Expand All @@ -144,27 +185,6 @@ impl AutoStartManager {
}
}

#[derive(Debug, Eq, PartialEq)]
enum AutoStartSyncAction {
None,
Enable,
Disable,
}

const fn autostart_sync_action(enabled: bool, current_enabled: bool) -> AutoStartSyncAction {
if enabled {
if current_enabled {
AutoStartSyncAction::None
} else {
AutoStartSyncAction::Enable
}
} else if current_enabled {
AutoStartSyncAction::Disable
} else {
AutoStartSyncAction::None
}
}

#[cfg(any(target_os = "macos", test))]
fn macos_app_bundle_path(exe_path: &Path) -> Option<String> {
let exe_str = exe_path.to_string_lossy();
Expand Down Expand Up @@ -259,27 +279,6 @@ mod tests {
);
}

#[test]
fn test_autostart_sync_action_enabled_preference_with_disabled_entry_enables() {
assert_eq!(
autostart_sync_action(true, false),
AutoStartSyncAction::Enable,
);
}

#[test]
fn test_autostart_sync_action_current_enabled_entry_is_noop() {
assert_eq!(autostart_sync_action(true, true), AutoStartSyncAction::None);
}

#[test]
fn test_autostart_sync_action_disabled_preference_disables_enabled_entry() {
assert_eq!(
autostart_sync_action(false, true),
AutoStartSyncAction::Disable,
);
}

// Pure-string Scoop normalisation tests — no OS state, safe to run
// in parallel with each other on any platform.

Expand Down
Loading
Loading