Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3a082219a | ||
|
|
f18b6955a9 | ||
|
|
c4e572b857 | ||
|
|
4f4c397970 | ||
|
|
fb2e1cc55c | ||
|
|
d2c5695221 | ||
|
|
6c793f530a |
No files matched your search
@@ -44,6 +44,12 @@ Workspaces are separate work areas for organizing applications. I38 provides 10
|
||||
`Control` + `F1` through `F10`: Switch to workspace 1 through 10.<br>
|
||||
`Control` + `Shift` + `F1` through `F10`: Move the focused window to workspace 1 through 10.<br>
|
||||
|
||||
When Cthulhu is running on this X display, I38 leaves workspace and window-move
|
||||
speech to Cthulhu. Keep its Reading options for i3 workspaces and workspace
|
||||
moves enabled. Switching to Orca restores I38's announcements automatically;
|
||||
workspace sounds and other I38-specific speech continue as configured. Mode
|
||||
sounds also remain available alongside Cthulhu's native mode announcements.
|
||||
|
||||
### Window Management
|
||||
|
||||
I38 uses a tabbed layout by default. In this layout, the focused window takes the screen, and you switch between windows much like switching between tabs.
|
||||
@@ -76,6 +82,7 @@ Ratpoison Mode provides quick access to common actions with short key sequences.
|
||||
`e`: Open the text editor, @TEXTEDITOR@.<br>
|
||||
`@MODKEY@` + `e`: Open the email client.<br>
|
||||
`f`: Open the file manager, if one was configured.<br>
|
||||
`Control` + `Alt` + `d`: Create a desktop shortcut from the clipboard, a file, folder, program, web URL, or installed application.<br>
|
||||
`i`: Launch the IRC client, if one was configured.<br>
|
||||
`w`: Launch the web browser, @WEBBROWSER@.<br>
|
||||
`s`: Launch Steam Big Picture, if Steam is installed.<br>
|
||||
@@ -154,6 +161,7 @@ Panel Mode automatically returns you to Default Mode after a selection is made o
|
||||
`s`: Show system information.<br>
|
||||
`r`: Open the reminder panel, if remind is installed.<br>
|
||||
`n`: Launch the notes application.<br>
|
||||
`Shift` + `n`: Manage wired and Wi-Fi connections.<br>
|
||||
`b`: Open Bluetooth, if blueman was installed when the i3 config was generated.<br>
|
||||
`@MODKEY@` + `b`: Speak a quick battery status.<br>
|
||||
`Shift` + `b`: Show detailed battery information.<br>
|
||||
@@ -166,6 +174,47 @@ Panel Mode automatically returns you to Default Mode after a selection is made o
|
||||
|
||||
The system information panel, `s` in Panel Mode, displays system statistics such as CPU usage, memory usage, disk space, and network status.
|
||||
|
||||
### Network Connections
|
||||
|
||||
Press `Shift` + `n` in Panel Mode to open I38 Network. Use Up and Down to select
|
||||
a connection, then Tab to Connect, Disconnect, Refresh, Details, or Close.
|
||||
Selecting a row does not change connections. Rows include the connection name,
|
||||
adapter, state, and Wi-Fi security when available. Type a name prefix
|
||||
to narrow the list; Escape clears the search, then closes the window. Scans
|
||||
preserve the selected connection and do not reorder existing rows.
|
||||
|
||||
The panel shows nearby Wi-Fi networks and saved wired and Wi-Fi profiles.
|
||||
Connect supports existing profiles and new open, WPA Personal, WPA3 Personal,
|
||||
and enhanced-open Wi-Fi networks. New enterprise, WEP, hidden-network, VPN, and
|
||||
advanced IP/DNS configuration are outside this panel's scope. Details provides
|
||||
reviewable signal strength, connection, and address information. Signal changes
|
||||
do not rewrite list rows, avoiding repeated announcements. Internet connectivity is shown
|
||||
separately from the connection state; a connection alone does not prove Internet
|
||||
access, and captive portals may require signing in through a browser.
|
||||
|
||||
Wi-Fi passwords use a normal, labeled GTK password field with Show password and
|
||||
Remember password controls. NetworkManager owns saved profiles and passwords;
|
||||
I38 has no password store. Remembered passwords are saved with the system-wide
|
||||
connection, subject to NetworkManager's access controls. Unchecking Remember
|
||||
password on a new network saves its profile without the password and disables
|
||||
automatic connection for that profile. On an existing profile, unchecking it
|
||||
uses the supplied password for that attempt without changing saved credentials.
|
||||
Saving a replacement password requires permission to modify that profile.
|
||||
|
||||
Connection changes require the current login session to belong to Stormux's
|
||||
`network` group and NetworkManager to authorize the requested operation. After
|
||||
an administrator changes group membership, log out and back in. The GUI runs
|
||||
as your normal user. A polkit handler scoped to its own process declines
|
||||
administrator password challenges, including late challenges after a policy
|
||||
change. If this protection cannot be registered, changes remain disabled.
|
||||
Other applications' authentication agents are unaffected. Missing permissions
|
||||
leave connection information available and display an explanation.
|
||||
|
||||
Closing the panel leaves established connections running. Disconnect may allow
|
||||
NetworkManager to activate another saved connection; disable Wi-Fi to turn off
|
||||
the wireless radio. This does not disable wired networking. A hardware wireless
|
||||
block must be released using the device's hardware control.
|
||||
|
||||
### Battery Information
|
||||
|
||||
The battery report has two modes. Press `@MODKEY@` + `b` in Panel Mode for a concise spoken announcement of charge level and charging state. Press `Shift` + `b` in Panel Mode for the detailed report. The detailed report is shown in a reviewable text dialog instead of spoken aloud, so the full details can be navigated with the arrow keys.
|
||||
@@ -270,6 +319,15 @@ I38 provides audio feedback for many actions.
|
||||
- Mode sounds confirm when you enter or leave a mode.
|
||||
- Workspace sounds confirm workspace changes.
|
||||
- Fullscreen sounds confirm fullscreen changes.
|
||||
- Optional USB alerts play a rising sound on connection and its reverse on removal.
|
||||
|
||||
Enable USB alerts separately during setup. With `python-pyudev` installed, I38
|
||||
uses SoX for sounds and Speech Dispatcher for spoken device names, falling back
|
||||
to "USB device connected" or "USB device disconnected" when a product name is
|
||||
unavailable. Disconnects use cached names. A wireless receiver may report its
|
||||
own name instead of the keyboard or mouse paired with it. Existing devices are
|
||||
recorded silently at startup; alerts indicate USB attachment, not mounting or
|
||||
device readiness.
|
||||
|
||||
## Application Menu and Running Programs
|
||||
|
||||
@@ -343,6 +401,14 @@ This is only available if you chose multiple keyboard layouts during setup.
|
||||
|
||||
## Desktop and Window Decorations
|
||||
|
||||
### Creating desktop shortcuts
|
||||
|
||||
Enter Ratpoison Mode with `@RATPOISONKEY@`, then press `Control` + `Alt` + `d`. Choose **Add from Clipboard**, **Browse for File**, **Browse for Folder**, or **Search for Application**. Copy one item at a time. You can also enter a path such as `/usr/bin/firefox` or `~/Documents`, a program name, or a complete HTTP/HTTPS URL in the Target field and press Enter.
|
||||
|
||||
The **Shortcut name** edit box contains a suggested name. For a web link, I38 looks up the page title, using the hostname if the title is unavailable. Edit the name if desired, then press **Create Shortcut** to confirm. Nothing is written until you confirm, and a late page title never replaces your edits. Use **Run program in a terminal** for command-line programs. Folders open in your selected file manager; files and web links use their default applications. Application shortcuts preserve the original launcher settings and require that original launcher to remain available. Repeated names receive numbered filenames. Deleting a created shortcut removes only the shortcut.
|
||||
|
||||
### Desktop navigation
|
||||
|
||||
I38 uses minimal window decorations. Windows do not use the usual title bars with minimize and maximize buttons. Instead, windows fill their available space automatically, and most interactions are performed through keyboard shortcuts.
|
||||
|
||||
`@MODKEY@` + `Control` + `d`: Open the I38 Desktop dialog. Use Up and Down to move through items, hold Shift with Up or Down to select multiple items, and press Control+Space to clear the selection. Type to search, press Enter to open the focused item, or press Shift+F10 for a context menu. Escape clears an active search and returns to the list; press it again to close the dialog. Right-click opens the context menu without clearing selected items. Delete permanently removes selected desktop files or folders after confirmation, including the contents of selected folders. Shortcut entries are kept. Use Tab and Shift+Tab to reach the search field and buttons.<br>
|
||||
|
||||
@@ -24,6 +24,7 @@ Required for the setup script and generated I38 configuration:
|
||||
- python-gobject: For the accessible GTK application menu, run dialog, and desktop dialog.
|
||||
- python-i3ipc: For i3 integration scripts.
|
||||
- rsync: For installing I38 scripts without generated Python cache files.
|
||||
- A supported terminal emulator: mate-terminal, lxterminal, gnome-terminal, terminator, xfce4-terminal, tilix, ptyxis, kgx, sakura, roxterm, termit, guake, tilda, qterminal, or konsole; alternatively, both xterm and fenrir. Setup checks for one before asking configuration questions.
|
||||
- yad: For screen reader accessible dialogs.
|
||||
- xdg-utils: For opening files and URLs from the run dialog.
|
||||
- xdotool: For X11 window interaction.
|
||||
@@ -36,14 +37,17 @@ Optional features use these packages when installed:
|
||||
- libnotify: For sending notifications
|
||||
- magic-wormhole: [optional] for file sharing with magic-wormhole GUI
|
||||
- notification-daemon: To handle notifications
|
||||
- networkmanager: [optional] for the Network panel, including libnm's GObject introspection data.
|
||||
- pamixer: for the mute-unmute script
|
||||
- pcmanfm: [optional] Graphical file manager.
|
||||
- playerctl: music controls
|
||||
- python-pillow: For OCR
|
||||
- python-pytesseract: For OCR
|
||||
- python-pyudev: [optional] for USB connection and removal alerts.
|
||||
- remind: [optional] For reminder notifications, Requires notify-daemon and notify-send for automatic reminders.
|
||||
- scrot: For OCR
|
||||
- sox: for sounds.
|
||||
- speech-dispatcher: [optional] provides `spd-say` for spoken USB alerts and other I38 announcements.
|
||||
- tesseract: For OCR
|
||||
- xprintidle: [optional] For screen lock autolock on X11/i3
|
||||
- i38lock: [optional] Secure accessible screen locker. On Stormux, install it from the repository. It requires Cthulhu or Orca 50 or newer for screen reader feedback.
|
||||
@@ -75,6 +79,154 @@ During setup, the screen reader picker includes Cthulhu, Orca, and None. If no s
|
||||
|
||||
During setup, I38 can configure one screen lock method. The legacy I38 PIN lock is a privacy screen only. I38Lock is a true PAM-backed screen locker, available at https://git.stormux.org/storm/i38lock and packaged in the Stormux repository. I38 offers I38Lock only when `i38lock` is installed and the screen-reader setup can support it: Cthulhu, or Orca 50 or newer. I38 does not enable the PIN lock and I38Lock at the same time.
|
||||
|
||||
## Screen reader announcements
|
||||
|
||||
I38 leaves workspace-switch and window-workspace-move speech to Cthulhu when
|
||||
its native session-bus service is running on the current X display. Detection
|
||||
runs for each announcement, so switching to Orca or stopping Cthulhu restores
|
||||
I38's speech immediately. Installation or saved screen-reader preferences alone
|
||||
do not suppress speech; an unavailable bus or unreadable process also leaves
|
||||
I38 speech enabled.
|
||||
|
||||
Workspace and mode sounds remain enabled as configured. I38's mode feedback is
|
||||
sound only; Cthulhu supplies its own mode speech. Battery, USB, OCR, brightness,
|
||||
keyboard-layout, and other I38-specific announcements continue as before.
|
||||
Keep Cthulhu's Reading choices “Announce i3 workspaces” and “Announce i3 window
|
||||
workspace moves” enabled for its replacement speech. Turning those choices off
|
||||
leaves those events silent while Cthulhu is active.
|
||||
|
||||
For an existing installation, run `./i38.sh -u` to copy the updated workspace
|
||||
script and shared helper. Regenerate the I38 configuration and run
|
||||
`i3-msg reload` to update window-move bindings too; copying scripts alone does
|
||||
not change those bindings. Custom bindings which invoke `spd-say` directly need
|
||||
to use `scripts/announce_i3.py` for overlapping i3 speech.
|
||||
|
||||
Run `python3 -B -m unittest discover -s tests -v` for automated checks. After
|
||||
reviewing the diff and updating the installation, use Control+F1/F2 to switch
|
||||
workspaces and Control+Shift+F1/F2 to move an expendable window. With Cthulhu,
|
||||
expect its native speech once per real change and the usual I38 sounds. Switch
|
||||
to Orca and repeat: expect I38 workspace and move speech. Switch back without
|
||||
regenerating and confirm duplicate speech stops. Enter/leave Ratpoison, Panel,
|
||||
Personal (if configured), and Bypass modes; check existing mode sounds and
|
||||
Cthulhu's native mode speech. Also check battery status, OCR, and USB alerts if
|
||||
enabled. Automated checks do not prove audible output or physical key behavior.
|
||||
|
||||
## Network Panel (Optional)
|
||||
|
||||
Press Control+Alt+Tab, then Shift+N to open the accessible wired/Wi-Fi panel.
|
||||
It requires NetworkManager/libnm 1.30 or newer and its `NM-1.0` introspection
|
||||
data, plus I38's existing Python/PyGObject/GTK 3 dependencies. On Stormux,
|
||||
connection management requires membership in `network` in the current session
|
||||
and an appropriate polkit policy. The GUI never elevates or requests an
|
||||
administrator password. If permissions or its process-specific protection
|
||||
against authentication prompts are unavailable, it remains usable for viewing
|
||||
connection information. No group membership or policy is changed by the panel.
|
||||
|
||||
Try the source directly before updating your installation:
|
||||
|
||||
```bash
|
||||
python3 -B scripts/network.py
|
||||
```
|
||||
|
||||
Use the arrows to select a connection; Tab to Connect, Disconnect, Refresh,
|
||||
Details, or Close. Selection alone does not connect or disconnect. Type a
|
||||
case-insensitive name prefix to filter. Escape clears the filter, then closes
|
||||
the window. Nearby access points with the same SSID and security are combined
|
||||
per adapter, while distinct saved profiles remain selectable. Existing rows
|
||||
retain their order and selection when scans change. Signal strength is available
|
||||
in Details; fluctuations do not rewrite the focused row.
|
||||
|
||||
New personal Wi-Fi networks use an accessible GTK password dialog. Remember
|
||||
password saves through NetworkManager with the system-wide profile, under its
|
||||
access controls. Without remembering, a new profile is saved without its
|
||||
password and with autoconnect disabled. Existing profiles can request another
|
||||
password, including after a wrong-password failure. Saving a replacement
|
||||
requires modification permission; otherwise it is used only for that attempt.
|
||||
I38 stores no passwords itself. Enterprise/hidden-network setup, VPN setup, and
|
||||
advanced IP/DNS editing are not included. Closing the window leaves established
|
||||
connections running. See the Network Connections section of the I38 guide.
|
||||
|
||||
For the installed binding, review the diff and regenerate your I38 configuration,
|
||||
then reload i3. `./i38.sh -u` copies scripts but does not add Shift+N to existing
|
||||
bindings. Regeneration uses your saved preferences; review them first.
|
||||
|
||||
Automated checks (no host connections are changed):
|
||||
|
||||
```bash
|
||||
dbus-run-session -- env I38_PRIVATE_NETWORK_TEST=1 python3 -B -m unittest discover -s tests -p test_network.py -v
|
||||
xvfb-run -a dbus-run-session -- env -u AT_SPI_BUS_ADDRESS I38_PRIVATE_NETWORK_GUI_TEST=1 python3 -B tests/native_network_gui.py -v
|
||||
```
|
||||
|
||||
For a native check against the running NetworkManager and polkit services, add
|
||||
`I38_NETWORK_LIVE_READONLY_TEST=1` to the second command's environment. It reads
|
||||
connection information, registers the panel's temporary agents, and checks that
|
||||
a polkit challenge for this process is declined when the local policy offers
|
||||
one. It does not activate, disconnect, scan, or modify connections or policy.
|
||||
|
||||
Before submission, review and test the changes as required by this project's
|
||||
AI-assisted contributions policy. For live testing with Cthulhu and Orca:
|
||||
|
||||
1. Open the source panel. Check initial selection, complete row announcements,
|
||||
prefix search, Tab/Shift+Tab, status review, Details, and Escape. Check Braille
|
||||
if available. Refresh must preserve focus and selection without repeated
|
||||
signal-strength announcements.
|
||||
2. Join a test Wi-Fi network. Check password masking, Show password, Remember
|
||||
password, cancellation, an incorrect password, a correct retry, and reopening
|
||||
the panel. Verify remembered credentials reconnect and an unremembered new
|
||||
profile requires its password again. No administrator dialog should appear.
|
||||
3. Check an existing wired connection and Wi-Fi, including both active together,
|
||||
disconnect/reconnect, Wi-Fi disabled/enabled, and hardware wireless blocking
|
||||
if available. Confirm normal networking still works after the panel closes.
|
||||
4. If a restricted test account is available, check that missing `network`
|
||||
membership or denied authorization leaves information readable and changes
|
||||
disabled. A policy change while a password dialog is open must be rechecked.
|
||||
5. After configuration regeneration, check Panel Mode Shift+N, existing lowercase
|
||||
`n` for Notes, F1 help, and the other panel bindings. Opening the network panel
|
||||
twice should focus its existing window.
|
||||
|
||||
Private GTK and D-Bus checks do not establish audible speech, Braille, physical
|
||||
key behavior, or successful live connection/password persistence.
|
||||
|
||||
## USB Connection Alerts (Optional)
|
||||
|
||||
Setup offers USB connection sounds and spoken device names separately from window
|
||||
event sounds. Default configuration mode enables USB alerts. Install
|
||||
`python-pyudev`, plus SoX for sounds and Speech Dispatcher (`spd-say`) for speech.
|
||||
If either output command is absent, the available output still works.
|
||||
|
||||
Connections play a 0.65-second rising, pulsing triangle tone; removals play the
|
||||
same sound reversed. Alerts use the device's product and manufacturer names,
|
||||
with `USB device connected` or `USB device disconnected` when no product name is
|
||||
available. Names are cached so unplugged devices can still be identified.
|
||||
Wireless receivers may expose only their receiver name, not the names of paired
|
||||
keyboards or mice. These alerts describe USB attachment, not successful mounting
|
||||
or readiness for use.
|
||||
|
||||
The listener observes udev events without changing rules, claiming devices, or
|
||||
mounting storage. It filters out USB interface events, silently records devices
|
||||
already attached at startup, and allows one listener per X display. It survives
|
||||
i3 restarts and exits when i3 logs out. Speech uses `spd-say -P important -w`
|
||||
without globally cancelling other speech.
|
||||
|
||||
After updating, regenerate the I38 configuration and enable the USB option;
|
||||
`./i38.sh -u` alone copies scripts without adding the startup entry. Saved
|
||||
preferences use `usbNotifications=0` to enable alerts and `usbNotifications=1`
|
||||
to disable them. To try the source listener before regeneration, run
|
||||
`python3 scripts/usb_notifications.py` in a terminal within your i3 session,
|
||||
then press Control+C when finished. Stop that foreground trial before testing
|
||||
the installed listener.
|
||||
|
||||
Run the focused checks with
|
||||
`python3 -B -m unittest discover -s tests -p 'test_usb_notifications.py' -v`.
|
||||
For human review, connect and remove a named USB device, an unnamed device if
|
||||
available, and a multifunction device: expect one alert per USB device, including
|
||||
the cached name on removal. Devices behind a hub each generate their own alert.
|
||||
Restart i3 and check that subsequent hotplug events are announced once and that
|
||||
devices already attached do not produce startup alerts. Confirm normal keyboard,
|
||||
mouse, storage mounting, existing window/mode sounds, and other speech still
|
||||
work. Check the USB setup option both enabled and disabled. Automated checks do
|
||||
not establish audible sound or spoken output.
|
||||
|
||||
## GTK Application Sound Themes (Optional)
|
||||
|
||||
This section is completely optional and separate from I38's window manager sounds. If you want GTK-based applications (like file managers, terminal emulators, text editors, etc.) to play sounds for their own events (button presses, menu navigation, dialog boxes, etc.), you can configure a GTK sound theme.
|
||||
@@ -126,6 +278,20 @@ Press the configured desktop shortcut, Mod+Control+D (Super+Control+D or Alt+Con
|
||||
|
||||
Quick-launch shortcut entries come from `~/.config/i3/desktop_shortcuts`. They are not deleted from disk by the desktop dialog.
|
||||
|
||||
### Creating desktop shortcuts
|
||||
|
||||
Enter Ratpoison Mode, then press Control+Alt+D to open **I38 Create Desktop Shortcut**. Choose **Add from Clipboard**, **Browse for File**, **Browse for Folder**, or **Search for Application**. You can also type an absolute path such as `/usr/bin/firefox`, a home-relative path such as `~/Documents`, a program name on PATH, or a complete HTTP/HTTPS URL in the Target field, then press Enter. Copy one item at a time; copying a file in a file manager and copying its path as text both work. Copied files are left in place, including files marked for cutting.
|
||||
|
||||
Review the suggested shortcut name, edit it if desired, then choose **Create Shortcut**. Web links use the page title when available, with the site hostname as a fallback. The title lookup runs in the background with a size limit and short network timeouts, without browser cookies or JavaScript. It never replaces a name you have edited. Files open in their default application, folders open in I38's selected file manager, and executable programs run directly. For command-line programs, select **Run program in a terminal**. Target accepts a program or path, rather than a shell command with arguments.
|
||||
|
||||
Application search lists registered visible applications, including user and system application launchers, with case-insensitive name-prefix filtering like the desktop dialog. Type to search and press Down to move into the list; press Enter to select an application. The shortcut launches the original `.desktop` entry, preserving its terminal, working-directory, D-Bus, and launch options. Keep that original entry available. You can also browse to or paste a `.desktop` file directly.
|
||||
|
||||
Windows `.exe` programs run with Wine, using the program's folder as the working directory. The creator looks for an enclosing Wine prefix (bottle), including through symbolic links, then checks the existing `~/.wine` prefix. It recognizes prefixes by their `drive_c`, `dosdevices`, and `system.reg` entries. Review or change the suggested **Wine prefix (bottle) folder**; if none is found, enter or browse for the existing prefix before creating the shortcut. Wine must be installed. Existing shortcuts are not updated automatically; recreate a shortcut made before this support was added.
|
||||
|
||||
Shortcuts are ordinary `.desktop` files in the XDG desktop directory (normally `~/Desktop`). Existing items are never overwritten; repeated names get numbered filenames. Tab and Shift+Tab move between controls, and Escape closes the dialog. Deleting a created shortcut through I38 Desktop removes only the shortcut file.
|
||||
|
||||
After updating an existing installation, regenerate your I38 configuration to add the new Ratpoison binding; `./i38.sh -u` copies scripts but does not add bindings. Review your configuration preferences before regeneration. Then reload i3 with `i3-msg reload`, or enter Ratpoison Mode and press Control+semicolon, to activate the new binding in the running session.
|
||||
|
||||
## Dependency Helpers
|
||||
|
||||
Distro-specific dependency helpers live in `extra/`. On Arch Linux, run:
|
||||
@@ -150,6 +316,16 @@ git config --local core.hooksPath .githooks
|
||||
The pre-commit hook rejects staged Python cache files. The I38 script installer
|
||||
also excludes and removes Python cache artifacts when copying `scripts/`.
|
||||
|
||||
Shortcut regression tests use Python's unittest, PyGObject, and `desktop-file-validate` (desktop-file-utils). Run `python3 -B -m unittest discover -s tests -v`. These check Wine prefix detection, desktop-entry argument handling and working directory using a recording Wine substitute, and existing target types. For native GTK Wine-prefix field and validation checks, also install Xvfb, then run:
|
||||
|
||||
```bash
|
||||
xvfb-run -a dbus-run-session -- env -u AT_SPI_BUS_ADDRESS I38_PRIVATE_GUI_TEST=1 PYTHONDONTWRITEBYTECODE=1 python3 tests/native_shortcut_gui.py -v
|
||||
```
|
||||
|
||||
The GUI test uses a separate display and session bus. It does not prove live speech or Braille. After reviewing the diff and regenerating your configuration, test the new Ratpoison binding, clipboard and browse sources, application search, edited names, and opening each shortcut type through I38 Desktop. Confirm that normal desktop prefix search and existing launchers still work, that repeated names preserve previous shortcuts, and that deleting a generated shortcut leaves its original target intact.
|
||||
|
||||
For Wine, create a shortcut to an `.exe` inside a non-default prefix and confirm that the suggested prefix is correct and the program opens with its settings and resources. Try an `.exe` outside any prefix: review the `~/.wine` suggestion when that prefix exists, or select the bottle manually when it does not. Test an explicit override, cancel the bottle chooser, and verify that an invalid folder prevents creation. Check that switching to a native program or document hides the Wine controls and preserves its normal launch behavior.
|
||||
|
||||
### AI-assisted contributions
|
||||
|
||||
AI-assisted contributions are welcome.
|
||||
|
||||
@@ -193,6 +193,10 @@ add_optional_dependencies() {
|
||||
command_exists playerctl || add_install_package playerctl
|
||||
command_exists scrot || add_install_package scrot
|
||||
command_exists sox || add_install_package sox
|
||||
command_exists spd-say || add_install_package speech-dispatcher
|
||||
if ! python_import_works 'import pyudev'; then
|
||||
add_install_package python-pyudev
|
||||
fi
|
||||
command_exists udiskie || add_install_package udiskie
|
||||
command_exists xclip || add_install_package xclip
|
||||
command_exists xprintidle || add_install_package xprintidle
|
||||
|
||||
@@ -43,6 +43,18 @@ if command -v python3 &> /dev/null ; then
|
||||
missing+=("python-gobject")
|
||||
fi
|
||||
fi
|
||||
availableTerminals=()
|
||||
for i in mate-terminal lxterminal gnome-terminal terminator xfce4-terminal tilix ptyxis kgx sakura roxterm termit guake tilda qterminal konsole ; do
|
||||
if command -v "$i" &> /dev/null ; then
|
||||
availableTerminals+=("$i")
|
||||
fi
|
||||
done
|
||||
if command -v xterm &> /dev/null && command -v fenrir &> /dev/null ; then
|
||||
availableTerminals+=("xterm")
|
||||
fi
|
||||
if [[ ${#availableTerminals[@]} -eq 0 ]]; then
|
||||
missing+=("supported terminal emulator (e.g. mate-terminal or lxterminal, or both xterm and fenrir)")
|
||||
fi
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
echo "Please install the following packages and run this script again:"
|
||||
echo "${missing[*]}"
|
||||
@@ -230,10 +242,7 @@ set_default_configuration() {
|
||||
textEditor="$(first_available_path emacs geany gedit kate kwrite l3afpad leafpad libreoffice mousepad pluma || true)"
|
||||
fileBrowser="$(first_available_path caja nemo nautilus pcmanfm pcmanfm-qt thunar || true)"
|
||||
ircClient="$(first_available_path albikirc Albikirc access-irc || true)"
|
||||
terminalEmulator="$(first_available_path mate-terminal lxterminal gnome-terminal terminator xfce4-terminal tilix ptyxis kgx sakura roxterm termit guake tilda qterminal konsole || true)"
|
||||
if [[ -z "$terminalEmulator" ]] && command -v xterm &> /dev/null && command -v fenrir &> /dev/null; then
|
||||
terminalEmulator="$(command -v xterm)"
|
||||
fi
|
||||
terminalEmulator="$(command -v "${availableTerminals[0]}")"
|
||||
udiskie=1
|
||||
if command -v udiskie &> /dev/null; then
|
||||
udiskie=0
|
||||
@@ -251,6 +260,7 @@ set_default_configuration() {
|
||||
brlapi=0
|
||||
fi
|
||||
sounds=0
|
||||
usbNotifications=0
|
||||
screenlockMethod="none"
|
||||
screenlockPinHash=""
|
||||
screenlockAutolockSeconds=0
|
||||
@@ -474,16 +484,6 @@ terminal_command() {
|
||||
fi
|
||||
}
|
||||
|
||||
screen_reader_command() {
|
||||
local screenReaderPath="$1"
|
||||
|
||||
if [[ "${screenReaderPath##*/}" == "cthulhu" ]]; then
|
||||
printf 'env ORC_CODE=backup %s' "$screenReaderPath"
|
||||
else
|
||||
printf '%s' "$screenReaderPath"
|
||||
fi
|
||||
}
|
||||
|
||||
load_config() {
|
||||
# Load existing configuration if available
|
||||
if [[ -f "$configFile" ]]; then
|
||||
@@ -537,6 +537,7 @@ dex="$dex"
|
||||
batteryAlert="${batteryAlert:-1}"
|
||||
brlapi="$brlapi"
|
||||
sounds="$sounds"
|
||||
usbNotifications="${usbNotifications:-1}"
|
||||
|
||||
# Screen lock
|
||||
screenlockMethod="${screenlockMethod:-none}"
|
||||
@@ -1004,28 +1005,12 @@ else
|
||||
export ircClient
|
||||
fi
|
||||
# Terminal emulator
|
||||
if [[ $defaultConfig -eq 1 && -z "$terminalEmulator" ]]; then
|
||||
echo "No supported terminal emulator was found. Please install mate-terminal, lxterminal, gnome-terminal, terminator, another supported accessible terminal, or install both xterm and fenrir."
|
||||
exit 1
|
||||
fi
|
||||
if [[ $defaultConfig -eq 0 ]] && { [[ -z "$terminalEmulator" ]] || { [[ ! -x "$terminalEmulator" ]] && ! command -v "$terminalEmulator" &> /dev/null; }; }; then
|
||||
configChanged=1
|
||||
programList=()
|
||||
for i in mate-terminal lxterminal gnome-terminal terminator xfce4-terminal tilix ptyxis kgx sakura roxterm termit guake tilda qterminal konsole ; do
|
||||
if command -v "$i" &> /dev/null ; then
|
||||
programList+=("$i")
|
||||
fi
|
||||
done
|
||||
if command -v xterm &> /dev/null && command -v fenrir &> /dev/null ; then
|
||||
programList+=("xterm")
|
||||
fi
|
||||
if [[ ${#programList[@]} -eq 0 ]]; then
|
||||
dialog --title "I38" --msgbox "No supported terminal emulator was found. Please install mate-terminal, lxterminal, gnome-terminal, terminator, another supported accessible terminal, or install both xterm and fenrir." -1 -1
|
||||
exit 1
|
||||
elif [[ ${#programList[@]} -gt 1 ]]; then
|
||||
terminalEmulator="$(menulist "Terminal emulator:" "${programList[@]}")"
|
||||
if [[ ${#availableTerminals[@]} -gt 1 ]]; then
|
||||
terminalEmulator="$(menulist "Terminal emulator:" "${availableTerminals[@]}")"
|
||||
else
|
||||
terminalEmulator="${programList[0]}"
|
||||
terminalEmulator="${availableTerminals[0]}"
|
||||
fi
|
||||
terminalEmulator="$(command -v "$terminalEmulator")"
|
||||
export terminalEmulator
|
||||
@@ -1056,7 +1041,7 @@ if [[ -z "$dex" ]]; then
|
||||
fi
|
||||
fi
|
||||
if [[ $dex -eq 0 ]] && [[ "$screenReader" != "none" ]] && [[ -n "$screenReader" ]]; then
|
||||
dex -t "${XDG_CONFIG_HOME:-${HOME}/.config}/autostart" -c "$(screen_reader_command "$screenReader")"
|
||||
dex -t "${XDG_CONFIG_HOME:-${HOME}/.config}/autostart" -c "$screenReader"
|
||||
fi
|
||||
if [[ -z "$batteryAlert" ]]; then
|
||||
if command -v acpi &> /dev/null ; then
|
||||
@@ -1083,6 +1068,14 @@ if [[ -z "$sounds" ]]; then
|
||||
sounds=1
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$usbNotifications" ]]; then
|
||||
configChanged=1
|
||||
if yesno "Do you want USB connection sounds and spoken device names? Requires python-pyudev, sox for sounds, and speech-dispatcher for announcements."; then
|
||||
usbNotifications=0
|
||||
else
|
||||
usbNotifications=1
|
||||
fi
|
||||
fi
|
||||
if [[ $defaultConfig -eq 0 && -z "$screenlockPinHash" ]]; then
|
||||
screenlockPinFile="${i3Path}/.screenpin"
|
||||
if [[ -f "$screenlockPinFile" ]]; then
|
||||
@@ -1197,7 +1190,6 @@ if [[ $configLoaded -eq 0 || $configChanged -eq 1 ]]; then
|
||||
fi
|
||||
|
||||
terminalExec="$(terminal_command "$terminalEmulator")"
|
||||
screenReaderExec="$(screen_reader_command "$screenReader")"
|
||||
|
||||
if [[ -d "${i3Path}" ]]; then
|
||||
yesno "This will replace your existing configuration at ${i3Path}. Do you want to continue?" || exit 0
|
||||
@@ -1368,25 +1360,25 @@ bindsym Control+F10 workspace number \$ws10, exec --no-startup-id ${i3Path}/scri
|
||||
|
||||
# move focused container to workspace
|
||||
# Move window to workspace 1
|
||||
bindsym Control+Shift+F1 move container to workspace number \$ws1, exec spd-say -P important -Cw "moved to workspace 1"
|
||||
bindsym Control+Shift+F1 move container to workspace number \$ws1, exec --no-startup-id ${i3Path}/scripts/announce_i3.py "moved to workspace 1"
|
||||
# Move window to workspace 2
|
||||
bindsym Control+Shift+F2 move container to workspace number \$ws2, exec spd-say -P important -Cw "moved to workspace 2"
|
||||
bindsym Control+Shift+F2 move container to workspace number \$ws2, exec --no-startup-id ${i3Path}/scripts/announce_i3.py "moved to workspace 2"
|
||||
# Move window to workspace 3
|
||||
bindsym Control+Shift+F3 move container to workspace number \$ws3, exec spd-say -P important -Cw "moved to workspace 3"
|
||||
bindsym Control+Shift+F3 move container to workspace number \$ws3, exec --no-startup-id ${i3Path}/scripts/announce_i3.py "moved to workspace 3"
|
||||
# Move window to workspace 4
|
||||
bindsym Control+Shift+F4 move container to workspace number \$ws4, exec spd-say -P important -Cw "moved to workspace 4"
|
||||
bindsym Control+Shift+F4 move container to workspace number \$ws4, exec --no-startup-id ${i3Path}/scripts/announce_i3.py "moved to workspace 4"
|
||||
# Move window to workspace 5
|
||||
bindsym Control+Shift+F5 move container to workspace number \$ws5, exec spd-say -P important -Cw "moved to workspace 5"
|
||||
bindsym Control+Shift+F5 move container to workspace number \$ws5, exec --no-startup-id ${i3Path}/scripts/announce_i3.py "moved to workspace 5"
|
||||
# Move window to workspace 6
|
||||
bindsym Control+Shift+F6 move container to workspace number \$ws6, exec spd-say -P important -Cw "moved to workspace 6"
|
||||
bindsym Control+Shift+F6 move container to workspace number \$ws6, exec --no-startup-id ${i3Path}/scripts/announce_i3.py "moved to workspace 6"
|
||||
# Move window to workspace 7
|
||||
bindsym Control+Shift+F7 move container to workspace number \$ws7, exec spd-say -P important -Cw "moved to workspace 7"
|
||||
bindsym Control+Shift+F7 move container to workspace number \$ws7, exec --no-startup-id ${i3Path}/scripts/announce_i3.py "moved to workspace 7"
|
||||
# Move window to workspace 8
|
||||
bindsym Control+Shift+F8 move container to workspace number \$ws8, exec spd-say -P important -Cw "moved to workspace 8"
|
||||
bindsym Control+Shift+F8 move container to workspace number \$ws8, exec --no-startup-id ${i3Path}/scripts/announce_i3.py "moved to workspace 8"
|
||||
# Move window to workspace 9
|
||||
bindsym Control+Shift+F9 move container to workspace number \$ws9, exec spd-say -P important -Cw "moved to workspace 9"
|
||||
bindsym Control+Shift+F9 move container to workspace number \$ws9, exec --no-startup-id ${i3Path}/scripts/announce_i3.py "moved to workspace 9"
|
||||
# Move window to workspace 10
|
||||
bindsym Control+Shift+F10 move container to workspace number \$ws10, exec spd-say -P important -Cw "moved to workspace 10"
|
||||
bindsym Control+Shift+F10 move container to workspace number \$ws10, exec --no-startup-id ${i3Path}/scripts/announce_i3.py "moved to workspace 10"
|
||||
|
||||
|
||||
# A mode that will pass all keys except $mod+shift+backspace to the current application.
|
||||
@@ -1451,6 +1443,9 @@ fi)
|
||||
# Simple notes system bound to n
|
||||
bindsym n exec --no-startup-id ${i3Path}/scripts/notes.py, mode "default"
|
||||
|
||||
# Network connections bound to Shift+n
|
||||
bindsym Shift+n exec --no-startup-id python3 ${i3Path}/scripts/network.py, mode "default"
|
||||
|
||||
# Password manager bound to m
|
||||
bindsym m exec --no-startup-id ${i3Path}/scripts/passmanager.py, mode "default"
|
||||
|
||||
@@ -1492,6 +1487,8 @@ bindsym $escapeKey mode "ratpoison"
|
||||
mode "ratpoison" {
|
||||
# Ratpoison mode keybindings help bound to F1
|
||||
bindsym F1 exec ${i3Path}/scripts/i38-help-rp.sh, mode "default"
|
||||
# Create a desktop shortcut bound to Control+Alt+d
|
||||
bindsym Control+Mod1+d exec --no-startup-id python3 ${i3Path}/scripts/shortcut_creator.py --file-manager '${fileBrowser}', mode "default"
|
||||
# Terminal emulator bound to c
|
||||
bindsym c exec $terminalExec, mode "default"
|
||||
# Text editor bound to e
|
||||
@@ -1557,7 +1554,7 @@ bindsym Mod1+Shift+r exec --no-startup-id ${i3Path}/scripts/slideshow_record_tog
|
||||
bindsym apostrophe exec --no-startup-id ${i3Path}/scripts/window_list.sh, mode "default"
|
||||
$(if command -v cthulhu &> /dev/null; then
|
||||
echo "# Restart Cthulhu"
|
||||
echo "bindsym Shift+c exec env ORC_CODE=backup $(command -v cthulhu) --replace, mode \"default\""
|
||||
echo "bindsym Shift+c exec $(command -v cthulhu) --replace, mode \"default\""
|
||||
fi)
|
||||
$(if command -v orca &> /dev/null; then
|
||||
echo "# Restart Orca"
|
||||
@@ -1591,6 +1588,9 @@ cat << EOF >> "${i3Path}/config"
|
||||
$(if [[ $sounds -eq 0 ]]; then
|
||||
echo "exec_always --no-startup-id ${i3Path}/scripts/sound.py"
|
||||
fi
|
||||
if [[ ${usbNotifications:-1} -eq 0 ]]; then
|
||||
echo "exec_always --no-startup-id python3 \"${i3Path}/scripts/usb_notifications.py\""
|
||||
fi
|
||||
if command -v xrdb &> /dev/null; then
|
||||
echo "exec --no-startup-id xrdb -merge ${i3Path}/I38.Xresources"
|
||||
fi
|
||||
@@ -1654,7 +1654,7 @@ else
|
||||
echo 'exec --no-startup-id x11bell play -nqV0 synth .1 sq norm -12'
|
||||
fi
|
||||
if [[ "$screenReader" != "none" ]] && [[ -n "$screenReader" ]]; then
|
||||
echo "exec $screenReaderExec"
|
||||
echo "exec $screenReader"
|
||||
fi
|
||||
fi)
|
||||
|
||||
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Speak i3 feedback only when Cthulhu is not handling it on this display."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
|
||||
def display_screen(display):
|
||||
"""An omitted X screen means screen zero; other screens have their own root."""
|
||||
host, separator, number = display.rpartition(':')
|
||||
if '.' not in number:
|
||||
number += '.0'
|
||||
return host + separator + number
|
||||
|
||||
|
||||
def cthulhu_is_active():
|
||||
display = os.environ.get('DISPLAY', '')
|
||||
if not display:
|
||||
return False
|
||||
try:
|
||||
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
# Query the bus daemon, never activate a screen reader or call speech.
|
||||
owner = connection.call_sync(
|
||||
'org.freedesktop.DBus', '/org/freedesktop/DBus',
|
||||
'org.freedesktop.DBus', 'GetNameOwner',
|
||||
GLib.Variant('(s)', ('org.stormux.Cthulhu1.Service',)),
|
||||
GLib.VariantType.new('(s)'), Gio.DBusCallFlags.NONE, 500, None
|
||||
).unpack()[0]
|
||||
processId = connection.call_sync(
|
||||
'org.freedesktop.DBus', '/org/freedesktop/DBus',
|
||||
'org.freedesktop.DBus', 'GetConnectionUnixProcessID',
|
||||
GLib.Variant('(s)', (owner,)), GLib.VariantType.new('(u)'),
|
||||
Gio.DBusCallFlags.NONE, 500, None
|
||||
).unpack()[0]
|
||||
processPath = Path('/proc') / str(processId)
|
||||
if processPath.stat().st_uid != os.getuid():
|
||||
return False
|
||||
for entry in (processPath / 'environ').read_bytes().split(b'\0'):
|
||||
if entry.startswith(b'DISPLAY='):
|
||||
readerDisplay = os.fsdecode(entry[len(b'DISPLAY='):])
|
||||
return display_screen(readerDisplay) == display_screen(display)
|
||||
except (GLib.Error, OSError):
|
||||
# Uncertain detection must preserve announcements for other readers.
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
return 0
|
||||
if cthulhu_is_active():
|
||||
return 0
|
||||
return subprocess.call(['spd-say', '-P', 'important', '-Cw', '--',
|
||||
' '.join(sys.argv[1:])])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
|
||||
path="$(readlink -f "$0")"
|
||||
path="${path%/*/*}"
|
||||
path="${path%/*}"
|
||||
workSpace="$(i3-msg -t get_workspaces \
|
||||
| jq '.[] | select(.focused==true).name' \
|
||||
| cut -d"\"" -f2)"
|
||||
@@ -21,7 +21,7 @@ right=0
|
||||
msg="Workspace ${workSpace}"
|
||||
if [[ "${workSpace}" == "11" ]]; then
|
||||
play -qnV0 synth 1.5 pl A4 pl E5 pl C5 delay 0.0 0.1 0.2 remix - fade p 0 1.5 .5
|
||||
spd-say -P important -Cw "I38 panel"
|
||||
python3 "${path}/announce_i3.py" "I38 panel"
|
||||
exit 0
|
||||
fi
|
||||
if ! [[ "${workSpace}" =~ ^[0-9]+$ ]]; then
|
||||
@@ -40,4 +40,4 @@ else
|
||||
fi
|
||||
fi
|
||||
play -nqV0 synth pi fade 0 .25 .15 pad 0 1 reverb overdrive riaa norm -8 speed 1 remix v0.${left} v0.${right} &
|
||||
spd-say -P important -Cw "${msg}"
|
||||
python3 "${path}/announce_i3.py" "${msg}"
|
||||
+6
-1
@@ -26,6 +26,8 @@ gi.require_version("Gtk", "3.0")
|
||||
|
||||
from gi.repository import Gdk, Gio, GLib, Gtk
|
||||
|
||||
from shortcuts import desktop_directory
|
||||
|
||||
try:
|
||||
gi.require_version("GioUnix", "2.0")
|
||||
from gi.repository import GioUnix
|
||||
@@ -100,7 +102,10 @@ def desktop_entry_name(path):
|
||||
|
||||
|
||||
def read_desktop_items():
|
||||
desktop_path = Path.home() / "Desktop"
|
||||
try:
|
||||
desktop_path = desktop_directory()
|
||||
except ValueError:
|
||||
return []
|
||||
if not desktop_path.is_dir():
|
||||
return []
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Accessible, ordinary-user NetworkManager panel for I38.
|
||||
|
||||
Copyright (C) 2026 I38 contributors
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Atk", "1.0")
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Atk, Gdk, Gio, Gtk
|
||||
|
||||
try:
|
||||
from network_backend import (MODIFY_SYSTEM, NETWORK_CONTROL, NM, NetworkBackend,
|
||||
security_type, valid_password)
|
||||
except (ImportError, ValueError):
|
||||
NetworkBackend = None
|
||||
|
||||
|
||||
def labeled_entry(labelText, entry):
|
||||
label = Gtk.Label.new_with_mnemonic(labelText)
|
||||
label.set_xalign(0)
|
||||
label.set_mnemonic_widget(entry)
|
||||
return label
|
||||
|
||||
|
||||
class PasswordDialog(Gtk.Dialog):
|
||||
def __init__(self, parent, name, keyMgmt, canRemember):
|
||||
super().__init__(title="Wi-Fi password", transient_for=parent, modal=True)
|
||||
self.keyMgmt = keyMgmt
|
||||
self.add_button("_Cancel", Gtk.ResponseType.CANCEL)
|
||||
self.connectButton = self.add_button("_Connect", Gtk.ResponseType.OK)
|
||||
self.set_default_response(Gtk.ResponseType.OK)
|
||||
box = self.get_content_area()
|
||||
box.set_spacing(10)
|
||||
box.set_border_width(12)
|
||||
heading = Gtk.Label(label="Enter the Wi-Fi password for {}.".format(name))
|
||||
heading.set_line_wrap(True)
|
||||
box.add(heading)
|
||||
self.entry = Gtk.Entry()
|
||||
self.entry.set_visibility(False)
|
||||
self.entry.set_input_purpose(Gtk.InputPurpose.PASSWORD)
|
||||
self.entry.set_activates_default(True)
|
||||
box.add(labeled_entry("_Password:", self.entry))
|
||||
box.add(self.entry)
|
||||
show = Gtk.CheckButton.new_with_mnemonic("_Show password")
|
||||
show.connect("toggled", lambda button: self.entry.set_visibility(button.get_active()))
|
||||
box.add(show)
|
||||
self.remember = Gtk.CheckButton.new_with_mnemonic("_Remember password")
|
||||
self.remember.set_active(canRemember)
|
||||
self.remember.set_sensitive(canRemember)
|
||||
self.remember.get_accessible().set_description(
|
||||
"NetworkManager saves the password with this connection for system users."
|
||||
if canRemember else "Saving this password is not authorized. It will only be used for this attempt.")
|
||||
box.add(self.remember)
|
||||
self.validation = Gtk.Label(label="")
|
||||
self.validation.set_xalign(0)
|
||||
box.add(self.validation)
|
||||
self.entry.connect("changed", self.on_changed)
|
||||
self.on_changed(self.entry)
|
||||
self.connect("key-press-event", self.on_key)
|
||||
self.show_all()
|
||||
self.entry.grab_focus()
|
||||
|
||||
def on_changed(self, entry):
|
||||
valid = valid_password(entry.get_text(), self.keyMgmt)
|
||||
self.connectButton.set_sensitive(valid)
|
||||
self.validation.set_text("" if valid else (
|
||||
"Use 8–63 characters or 64 hexadecimal digits." if self.keyMgmt == "wpa-psk"
|
||||
else "Enter a password of 1–63 UTF-8 bytes."))
|
||||
|
||||
def on_key(self, widget, event):
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
self.response(Gtk.ResponseType.CANCEL)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class NetworkWindow(Gtk.ApplicationWindow):
|
||||
def __init__(self, application, backendFactory=None):
|
||||
super().__init__(application=application, title="I38 Network")
|
||||
self.set_default_size(850, 500)
|
||||
self.backend = None
|
||||
self.passwordDialog = None
|
||||
self.resultDialog = None
|
||||
self.lastBusy = False
|
||||
self.updating = False
|
||||
self.rowsByKey = {}
|
||||
self.connect("destroy", self.on_destroy)
|
||||
self.connect("key-press-event", self.on_key)
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
box.set_border_width(12)
|
||||
self.add(box)
|
||||
|
||||
self.summary = Gtk.Label(label="Loading NetworkManager…")
|
||||
self.summary.set_xalign(0)
|
||||
self.summary.set_line_wrap(True)
|
||||
box.pack_start(self.summary, False, False, 0)
|
||||
self.wireless = Gtk.CheckButton.new_with_mnemonic("_Enable Wi-Fi")
|
||||
self.wireless.connect("toggled", self.on_wireless)
|
||||
box.pack_start(self.wireless, False, False, 0)
|
||||
|
||||
self.search = Gtk.SearchEntry()
|
||||
self.search.get_accessible().set_name("Find connection by name prefix")
|
||||
box.pack_start(labeled_entry("_Find connection:", self.search), False, False, 0)
|
||||
box.pack_start(self.search, False, False, 0)
|
||||
# A single text column keeps a row's complete state in one accessible cell.
|
||||
self.store = Gtk.ListStore(object, str, str)
|
||||
self.filtered = self.store.filter_new()
|
||||
self.filtered.set_visible_func(self.row_visible)
|
||||
self.tree = Gtk.TreeView(model=self.filtered)
|
||||
self.tree.set_headers_visible(False)
|
||||
self.tree.set_enable_search(False)
|
||||
self.tree.get_accessible().set_name("Network connections")
|
||||
self.tree.get_accessible().set_description(
|
||||
"Use Up and Down to select a connection, then Tab to Connect, Disconnect, or Details. Selection does not change the network.")
|
||||
self.tree.append_column(Gtk.TreeViewColumn("Connection", Gtk.CellRendererText(), text=1))
|
||||
self.tree.get_selection().connect("changed", self.on_selection)
|
||||
self.tree.connect("key-press-event", self.on_list_key)
|
||||
scroll = Gtk.ScrolledWindow()
|
||||
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
scroll.add(self.tree)
|
||||
box.pack_start(scroll, True, True, 0)
|
||||
self.search.connect("search-changed", self.on_search)
|
||||
|
||||
self.permissionText = Gtk.Label(label="Checking network permissions…")
|
||||
self.permissionText.set_xalign(0)
|
||||
self.permissionText.set_line_wrap(True)
|
||||
self.permissionText.set_selectable(True)
|
||||
box.pack_start(self.permissionText, False, False, 0)
|
||||
self.statusEntry = Gtk.Entry()
|
||||
self.statusEntry.set_editable(False)
|
||||
self.statusEntry.set_text("Loading connections…")
|
||||
self.statusEntry.get_accessible().set_role(Atk.Role.STATUSBAR)
|
||||
box.pack_start(labeled_entry("_Status:", self.statusEntry), False, False, 0)
|
||||
box.pack_start(self.statusEntry, False, False, 0)
|
||||
buttons = Gtk.Box(spacing=8)
|
||||
box.pack_start(buttons, False, False, 0)
|
||||
self.connectButton = self.add_button(buttons, "_Connect", self.on_connect)
|
||||
self.disconnectButton = self.add_button(buttons, "_Disconnect", self.on_disconnect)
|
||||
self.refreshButton = self.add_button(buttons, "_Refresh", lambda button: self.backend.scan())
|
||||
self.detailsButton = self.add_button(buttons, "De_tails", self.on_details)
|
||||
self.add_button(buttons, "Cl_ose", lambda button: self.close())
|
||||
self.show_all()
|
||||
if backendFactory is None:
|
||||
backendFactory = NetworkBackend
|
||||
if backendFactory:
|
||||
self.backend = backendFactory(self.refresh, self.on_status, self.request_password)
|
||||
self.refresh()
|
||||
else:
|
||||
self.on_status("NetworkManager's Python bindings are unavailable. Install NetworkManager/libnm 1.30 or newer and its GObject introspection data.")
|
||||
self.permissionText.set_text("Connection management is unavailable.")
|
||||
for button in (self.connectButton, self.disconnectButton, self.refreshButton, self.wireless):
|
||||
button.set_sensitive(False)
|
||||
self.tree.grab_focus()
|
||||
|
||||
def add_button(self, box, text, handler):
|
||||
button = Gtk.Button.new_with_mnemonic(text)
|
||||
button.connect("clicked", handler)
|
||||
box.add(button)
|
||||
return button
|
||||
|
||||
def selected_row(self):
|
||||
model, treeIter = self.tree.get_selection().get_selected()
|
||||
return self.rowsByKey.get(model[treeIter][0]) if treeIter is not None else None
|
||||
|
||||
def row_visible(self, model, treeIter, data):
|
||||
return model[treeIter][2].casefold().startswith(self.search.get_text().casefold())
|
||||
|
||||
def on_search(self, entry):
|
||||
self.filtered.refilter()
|
||||
if self.selected_row() is None and len(self.filtered):
|
||||
self.tree.set_cursor(Gtk.TreePath.new_first())
|
||||
self.on_selection()
|
||||
|
||||
def on_list_key(self, widget, event):
|
||||
if event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.MOD1_MASK):
|
||||
return False
|
||||
character = Gdk.keyval_to_unicode(event.keyval)
|
||||
if character and chr(character).isprintable():
|
||||
self.search.set_text(self.search.get_text() + chr(character))
|
||||
self.search.set_position(-1)
|
||||
return True
|
||||
if event.keyval == Gdk.KEY_BackSpace:
|
||||
self.search.set_text(self.search.get_text()[:-1])
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_key(self, widget, event):
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
if self.search.get_text():
|
||||
self.search.set_text("")
|
||||
self.tree.grab_focus()
|
||||
else:
|
||||
self.close()
|
||||
return True
|
||||
return False
|
||||
|
||||
def refresh(self):
|
||||
if self.backend is None:
|
||||
return
|
||||
oldRow = self.selected_row()
|
||||
oldKey = oldRow.key if oldRow else None
|
||||
rows = self.backend.rows()
|
||||
self.rowsByKey = {row.key: row for row in rows}
|
||||
desiredKeys = set(self.rowsByKey)
|
||||
# Update in place: scans and signal changes never rebuild the focused list.
|
||||
treeIter = self.store.get_iter_first()
|
||||
existing = set()
|
||||
while treeIter is not None:
|
||||
key = self.store[treeIter][0]
|
||||
if key not in desiredKeys:
|
||||
if not self.store.remove(treeIter):
|
||||
break
|
||||
continue
|
||||
existing.add(key)
|
||||
row = self.rowsByKey[key]
|
||||
text = self.row_text(row)
|
||||
if self.store[treeIter][1] != text:
|
||||
self.store[treeIter][1] = text
|
||||
if self.store[treeIter][2] != row.name:
|
||||
self.store[treeIter][2] = row.name
|
||||
treeIter = self.store.iter_next(treeIter)
|
||||
for row in rows:
|
||||
if row.key not in existing:
|
||||
self.store.append((row.key, self.row_text(row), row.name))
|
||||
selected = self.selected_row()
|
||||
if selected is None:
|
||||
firstPath = None
|
||||
for modelRow in self.filtered:
|
||||
row = self.rowsByKey[modelRow[0]]
|
||||
if firstPath is None:
|
||||
firstPath = modelRow.path
|
||||
if row.key == oldKey or (oldKey is None and row.active):
|
||||
firstPath = modelRow.path
|
||||
break
|
||||
if firstPath is not None:
|
||||
self.tree.set_cursor(firstPath)
|
||||
client = self.backend.client
|
||||
self.updating = True
|
||||
self.wireless.set_active(bool(client and client.wireless_get_enabled()))
|
||||
self.updating = False
|
||||
if not client or not client.get_nm_running():
|
||||
self.summary.set_text("NetworkManager is unavailable.")
|
||||
else:
|
||||
if self.statusEntry.get_text() == "Loading connections…":
|
||||
self.statusEntry.set_text("Select a connection, then choose Connect or Disconnect.")
|
||||
connectivity = client.get_connectivity()
|
||||
descriptions = {NM.ConnectivityState.FULL: "Internet connectivity confirmed",
|
||||
NM.ConnectivityState.PORTAL: "Network sign-in may be required",
|
||||
NM.ConnectivityState.LIMITED: "Limited connectivity",
|
||||
NM.ConnectivityState.NONE: "No Internet connectivity",
|
||||
NM.ConnectivityState.UNKNOWN: "Internet connectivity not checked"}
|
||||
activeNames = [row.name for row in rows if row.state == "Connected"]
|
||||
self.summary.set_text("{}. {}. {} connections listed.".format(
|
||||
"Connected: " + ", ".join(activeNames) if activeNames else "No active wired or Wi-Fi connection",
|
||||
descriptions.get(connectivity, "Internet connectivity unknown"), len(rows)))
|
||||
self.on_selection()
|
||||
self.refreshButton.set_sensitive(bool(client and not self.backend.busy and
|
||||
not self.backend.permission_problem([NM.ClientPermission.WIFI_SCAN])))
|
||||
self.wireless.set_sensitive(bool(client and not self.backend.busy and client.wireless_hardware_get_enabled() and
|
||||
not self.backend.permission_problem([NM.ClientPermission.ENABLE_DISABLE_WIFI])))
|
||||
self.lastBusy = self.backend.busy
|
||||
if not self.backend.secretRequest and self.passwordDialog is not None and self.backend.busy:
|
||||
self.passwordDialog.response(Gtk.ResponseType.CANCEL)
|
||||
|
||||
def row_text(self, row, includeSignal=False):
|
||||
parts = [row.name, row.kind, row.device.get_iface(), row.state]
|
||||
if includeSignal and row.signal:
|
||||
parts.append("signal " + row.signal)
|
||||
if row.security:
|
||||
parts.append(row.security)
|
||||
if row.profile:
|
||||
parts.append("saved")
|
||||
return ", ".join(parts)
|
||||
|
||||
def on_selection(self, *args):
|
||||
if not self.backend:
|
||||
return
|
||||
row = self.selected_row()
|
||||
permissions = [NETWORK_CONTROL] + ([MODIFY_SYSTEM] if row and not row.profile else [])
|
||||
problem = self.backend.permission_problem(permissions)
|
||||
self.permissionText.set_text(problem or "Connection management is authorized. Administrator passwords are never requested.")
|
||||
available = bool(row and row.state not in ("Unavailable", "Unmanaged", "Cable unplugged", "Hardware blocked", "Wi-Fi disabled"))
|
||||
supported = bool(row and (row.profile or security_type(row.ap)[1] is not None))
|
||||
self.connectButton.set_sensitive(bool(available and supported and not row.active and
|
||||
not problem and self.backend.agentReady and not self.backend.busy))
|
||||
self.disconnectButton.set_sensitive(bool(row and row.active and not self.backend.busy and
|
||||
not self.backend.permission_problem([NETWORK_CONTROL])))
|
||||
self.detailsButton.set_sensitive(row is not None)
|
||||
|
||||
def on_status(self, message):
|
||||
self.statusEntry.set_text(message)
|
||||
self.statusEntry.set_tooltip_text(message)
|
||||
if self.backend and self.lastBusy and not self.backend.busy:
|
||||
self.lastBusy = False
|
||||
if self.passwordDialog:
|
||||
self.passwordDialog.response(Gtk.ResponseType.CANCEL)
|
||||
self.show_message("Network operation", message)
|
||||
|
||||
def show_message(self, title, message):
|
||||
if self.resultDialog:
|
||||
self.resultDialog.destroy()
|
||||
dialog = Gtk.MessageDialog(transient_for=self, modal=True, message_type=Gtk.MessageType.INFO,
|
||||
buttons=Gtk.ButtonsType.CLOSE, text=title)
|
||||
dialog.format_secondary_text(message)
|
||||
dialog.connect("response", lambda widget, response: widget.destroy())
|
||||
dialog.connect("destroy", self.on_result_closed)
|
||||
self.resultDialog = dialog
|
||||
dialog.show_all()
|
||||
|
||||
def on_result_closed(self, dialog):
|
||||
if self.resultDialog == dialog:
|
||||
self.resultDialog = None
|
||||
|
||||
def request_password(self, name, keyMgmt, canRemember, callback):
|
||||
if self.passwordDialog:
|
||||
self.passwordDialog.response(Gtk.ResponseType.CANCEL)
|
||||
dialog = PasswordDialog(self, name, keyMgmt, canRemember)
|
||||
self.passwordDialog = dialog
|
||||
|
||||
def responded(widget, response):
|
||||
password = widget.entry.get_text() if response == Gtk.ResponseType.OK else None
|
||||
remember = widget.remember.get_active()
|
||||
widget.entry.set_text("")
|
||||
self.passwordDialog = None
|
||||
widget.destroy()
|
||||
callback(password, remember)
|
||||
|
||||
dialog.connect("response", responded)
|
||||
|
||||
def on_connect(self, button):
|
||||
row = self.selected_row()
|
||||
if not row:
|
||||
return
|
||||
permissions = [NETWORK_CONTROL] + ([] if row.profile else [MODIFY_SYSTEM])
|
||||
if not self.backend.authorize(permissions):
|
||||
return
|
||||
if row.profile or security_type(row.ap)[1] not in ("wpa-psk", "sae"):
|
||||
self.backend.connect_row(row)
|
||||
return
|
||||
|
||||
def answered(password, remember):
|
||||
if password is not None:
|
||||
self.backend.connect_row(row, password, remember)
|
||||
|
||||
self.request_password(row.name, security_type(row.ap)[1], True, answered)
|
||||
|
||||
def on_disconnect(self, button):
|
||||
row = self.selected_row()
|
||||
if row:
|
||||
self.backend.disconnect_row(row)
|
||||
|
||||
def on_wireless(self, button):
|
||||
if not self.updating and self.backend:
|
||||
self.backend.set_wireless(button.get_active())
|
||||
self.refresh()
|
||||
|
||||
def on_details(self, button):
|
||||
row = self.selected_row()
|
||||
if not row:
|
||||
return
|
||||
lines = [self.row_text(row, includeSignal=True)]
|
||||
if row.profile:
|
||||
lines.append("Profile UUID: " + row.profile.get_uuid())
|
||||
lines.append("Connect automatically: " + ("Yes" if row.profile.get_setting_connection().get_autoconnect() else "No"))
|
||||
if row.ap and security_type(row.ap)[1] is None:
|
||||
lines.append("Creating profiles for this security type is not supported. Existing profiles can be activated.")
|
||||
if row.active:
|
||||
for version, config in (("IPv4", row.device.get_ip4_config()), ("IPv6", row.device.get_ip6_config())):
|
||||
if config:
|
||||
lines.extend("{} address: {}/{}".format(version, address.get_address(), address.get_prefix()) for address in config.get_addresses())
|
||||
if config.get_gateway():
|
||||
lines.append("{} gateway: {}".format(version, config.get_gateway()))
|
||||
lines.append("{} DNS: {}".format(version, ", ".join(config.get_nameservers())))
|
||||
dialog = Gtk.Dialog(title="Connection details", transient_for=self, modal=True)
|
||||
dialog.add_button("_Close", Gtk.ResponseType.CLOSE)
|
||||
view = Gtk.TextView()
|
||||
view.set_editable(False)
|
||||
view.set_accepts_tab(False)
|
||||
view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
||||
view.get_accessible().set_name("Connection details")
|
||||
view.get_buffer().set_text("\n".join(lines))
|
||||
scroll = Gtk.ScrolledWindow()
|
||||
scroll.add(view)
|
||||
dialog.get_content_area().add(scroll)
|
||||
dialog.set_default_size(600, 350)
|
||||
dialog.connect("response", lambda widget, response: widget.destroy())
|
||||
dialog.show_all()
|
||||
view.grab_focus()
|
||||
|
||||
def on_destroy(self, widget):
|
||||
if self.backend:
|
||||
self.backend.close()
|
||||
if self.passwordDialog:
|
||||
self.passwordDialog.response(Gtk.ResponseType.CANCEL)
|
||||
if self.resultDialog:
|
||||
self.resultDialog.destroy()
|
||||
|
||||
|
||||
class NetworkApplication(Gtk.Application):
|
||||
def __init__(self):
|
||||
super().__init__(application_id="org.stormux.I38.Network", flags=Gio.ApplicationFlags.FLAGS_NONE)
|
||||
|
||||
def do_activate(self):
|
||||
window = self.get_active_window()
|
||||
if window is None:
|
||||
window = NetworkWindow(self)
|
||||
window.present()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(NetworkApplication().run(sys.argv))
|
||||
@@ -0,0 +1,571 @@
|
||||
#!/usr/bin/env python3
|
||||
"""NetworkManager operations for I38; no elevated GUI or private password store.
|
||||
|
||||
Copyright (C) 2026 I38 contributors
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
|
||||
import grp
|
||||
import locale
|
||||
import os
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("NM", "1.0")
|
||||
from gi.repository import Gio, GLib, GObject, NM
|
||||
|
||||
if (NM.MAJOR_VERSION, NM.MINOR_VERSION) < (1, 30):
|
||||
raise ValueError("I38 Network requires libnm 1.30 or newer.")
|
||||
|
||||
|
||||
NM_NAME = "org.freedesktop.NetworkManager"
|
||||
NM_PATH = "/org/freedesktop/NetworkManager"
|
||||
POLKIT_NAME = "org.freedesktop.PolicyKit1"
|
||||
POLKIT_PATH = "/org/freedesktop/PolicyKit1/Authority"
|
||||
AUTH_PATH = "/org/stormux/I38/NetworkAuthentication"
|
||||
SECRET_PATH = NM_PATH + "/SecretAgent"
|
||||
SECRET_INTERFACE = NM_NAME + ".SecretAgent"
|
||||
SECURITY_SETTING = "802-11-wireless-security"
|
||||
NETWORK_CONTROL = NM.ClientPermission.NETWORK_CONTROL
|
||||
MODIFY_SYSTEM = NM.ClientPermission.SETTINGS_MODIFY_SYSTEM
|
||||
MODIFY_OWN = NM.ClientPermission.SETTINGS_MODIFY_OWN
|
||||
ApFlags = getattr(NM, "80211ApFlags")
|
||||
ApSecurityFlags = getattr(NM, "80211ApSecurityFlags")
|
||||
|
||||
NetworkRow = namedtuple("NetworkRow", "key name kind state signal security device profile ap active")
|
||||
|
||||
AUTH_XML = """<node><interface name="org.freedesktop.PolicyKit1.AuthenticationAgent">
|
||||
<method name="BeginAuthentication"><arg type="s" direction="in"/>
|
||||
<arg type="s" direction="in"/><arg type="s" direction="in"/>
|
||||
<arg type="a{ss}" direction="in"/><arg type="s" direction="in"/>
|
||||
<arg type="a(sa{sv})" direction="in"/></method>
|
||||
<method name="CancelAuthentication"><arg type="s" direction="in"/></method>
|
||||
</interface></node>"""
|
||||
|
||||
SECRET_XML = """<node><interface name="org.freedesktop.NetworkManager.SecretAgent">
|
||||
<method name="GetSecrets"><arg type="a{sa{sv}}" direction="in"/>
|
||||
<arg type="o" direction="in"/><arg type="s" direction="in"/>
|
||||
<arg type="as" direction="in"/><arg type="u" direction="in"/>
|
||||
<arg type="a{sa{sv}}" direction="out"/></method>
|
||||
<method name="CancelGetSecrets"><arg type="o" direction="in"/>
|
||||
<arg type="s" direction="in"/></method>
|
||||
<method name="SaveSecrets"><arg type="a{sa{sv}}" direction="in"/>
|
||||
<arg type="o" direction="in"/></method>
|
||||
<method name="DeleteSecrets"><arg type="a{sa{sv}}" direction="in"/>
|
||||
<arg type="o" direction="in"/></method>
|
||||
</interface></node>"""
|
||||
|
||||
|
||||
def network_group_member():
|
||||
"""Check the groups of this session, not newly edited account records."""
|
||||
try:
|
||||
groupId = grp.getgrnam("network").gr_gid
|
||||
except KeyError:
|
||||
return False
|
||||
return groupId in set(os.getgroups()) | {os.getgid()}
|
||||
|
||||
|
||||
def security_type(ap):
|
||||
flags = ap.get_wpa_flags() | ap.get_rsn_flags()
|
||||
if flags & (ApSecurityFlags.KEY_MGMT_802_1X | ApSecurityFlags.KEY_MGMT_EAP_SUITE_B_192):
|
||||
return "Enterprise", None
|
||||
if flags & ApSecurityFlags.KEY_MGMT_PSK:
|
||||
return "WPA Personal", "wpa-psk"
|
||||
if flags & ApSecurityFlags.KEY_MGMT_SAE:
|
||||
return "WPA3 Personal", "sae"
|
||||
if flags & (ApSecurityFlags.KEY_MGMT_OWE | ApSecurityFlags.KEY_MGMT_OWE_TM):
|
||||
return "Enhanced open", "owe"
|
||||
if ap.get_flags() & ApFlags.PRIVACY:
|
||||
return "WEP (unsupported)", None
|
||||
return "Open", ""
|
||||
|
||||
|
||||
def valid_password(password, keyMgmt):
|
||||
if keyMgmt == "wpa-psk":
|
||||
return NM.utils_wpa_psk_valid(password)
|
||||
return keyMgmt == "sae" and 1 <= len(password.encode("utf-8")) <= 63
|
||||
|
||||
|
||||
def new_wifi_connection(row, password, remember):
|
||||
connection = NM.SimpleConnection.new()
|
||||
setting = NM.SettingConnection.new()
|
||||
setting.props.id = row.name
|
||||
setting.props.uuid = str(uuid.uuid4())
|
||||
setting.props.type = "802-11-wireless"
|
||||
setting.props.autoconnect = remember
|
||||
connection.add_setting(setting)
|
||||
wireless = NM.SettingWireless.new()
|
||||
wireless.props.ssid = row.ap.get_ssid()
|
||||
wireless.props.mode = "infrastructure"
|
||||
connection.add_setting(wireless)
|
||||
keyMgmt = security_type(row.ap)[1]
|
||||
if keyMgmt is None:
|
||||
raise ValueError("This Wi-Fi security type is not supported by I38 Network.")
|
||||
if keyMgmt:
|
||||
security = NM.SettingWirelessSecurity.new()
|
||||
security.props.key_mgmt = keyMgmt
|
||||
if keyMgmt in ("wpa-psk", "sae"):
|
||||
if not valid_password(password, keyMgmt):
|
||||
raise ValueError("The Wi-Fi password has an invalid length or format.")
|
||||
security.props.psk = password
|
||||
security.props.psk_flags = NM.SettingSecretFlags.NONE if remember else NM.SettingSecretFlags.NOT_SAVED
|
||||
connection.add_setting(security)
|
||||
connection.normalize()
|
||||
return connection
|
||||
|
||||
|
||||
class PromptGuard:
|
||||
"""Decline polkit challenges for this process, without replacing the session agent.
|
||||
|
||||
NetworkManager permits interactive authorization internally. Preflight checks
|
||||
cannot prevent a late challenge after a policy change. A unix-process agent
|
||||
takes precedence over the session agent and closes that race.
|
||||
"""
|
||||
|
||||
def __init__(self, bus, changed, status):
|
||||
self.bus = bus
|
||||
self.changed = changed
|
||||
self.status = status
|
||||
self.ready = False
|
||||
self.error = ""
|
||||
self.owner = None
|
||||
self.closed = False
|
||||
self.objectId = bus.register_object(
|
||||
AUTH_PATH, Gio.DBusNodeInfo.new_for_xml(AUTH_XML).interfaces[0], self.handle_call, None, None)
|
||||
self.watchId = Gio.bus_watch_name_on_connection(
|
||||
bus, POLKIT_NAME, Gio.BusNameWatcherFlags.NONE, self.on_appeared, self.on_vanished)
|
||||
|
||||
def on_appeared(self, bus, name, owner):
|
||||
self.owner = owner
|
||||
startTime = int(Path("/proc/self/stat").read_text().rsplit(")", 1)[1].split()[19])
|
||||
self.subject = ("unix-process", {"pid": GLib.Variant("u", os.getpid()),
|
||||
"start-time": GLib.Variant("t", startTime),
|
||||
"uid": GLib.Variant("i", os.getuid())})
|
||||
bus.call(owner, POLKIT_PATH, POLKIT_NAME + ".Authority", "RegisterAuthenticationAgent",
|
||||
GLib.Variant("((sa{sv})ss)", (self.subject, locale.setlocale(locale.LC_MESSAGES), AUTH_PATH)),
|
||||
None, Gio.DBusCallFlags.NONE, 10000, None, self.on_registered, owner)
|
||||
|
||||
def on_registered(self, bus, result, owner):
|
||||
try:
|
||||
bus.call_finish(result)
|
||||
self.ready = not self.closed and self.owner == owner
|
||||
except GLib.Error as error:
|
||||
self.ready = False
|
||||
self.error = error.message
|
||||
self.status("Cannot protect against administrator password prompts. Connection changes are disabled.")
|
||||
self.changed()
|
||||
|
||||
def on_vanished(self, bus, name):
|
||||
self.owner = None
|
||||
self.ready = False
|
||||
self.changed()
|
||||
|
||||
def handle_call(self, bus, sender, path, interface, method, parameters, invocation):
|
||||
if sender != self.owner:
|
||||
invocation.return_dbus_error(POLKIT_NAME + ".Error.NotAuthorized", "Unexpected sender")
|
||||
elif method == "BeginAuthentication":
|
||||
invocation.return_dbus_error(POLKIT_NAME + ".Error.Cancelled", "I38 does not request administrator passwords")
|
||||
self.status("System policy requires administrator authorization. This operation was declined; no password dialog was opened.")
|
||||
else:
|
||||
invocation.return_value(GLib.Variant("()", ()))
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
self.ready = False
|
||||
Gio.bus_unwatch_name(self.watchId)
|
||||
if self.owner and hasattr(self, "subject"):
|
||||
self.bus.call(self.owner, POLKIT_PATH, POLKIT_NAME + ".Authority", "UnregisterAuthenticationAgent",
|
||||
GLib.Variant("((sa{sv})s)", (self.subject, AUTH_PATH)), None,
|
||||
Gio.DBusCallFlags.NONE, 5000, None, None, None)
|
||||
self.bus.unregister_object(self.objectId)
|
||||
|
||||
|
||||
class NetworkBackend:
|
||||
def __init__(self, changed, status, password):
|
||||
self.changed = changed
|
||||
self.status = status
|
||||
self.password = password
|
||||
self.client = None
|
||||
self.guard = None
|
||||
self.agentReady = False
|
||||
self.closed = False
|
||||
self.busy = False
|
||||
self.pending = None
|
||||
self.secretRequest = None
|
||||
self.signalHandlers = []
|
||||
self.cancellable = Gio.Cancellable()
|
||||
self.refreshSource = 0
|
||||
self.member = network_group_member()
|
||||
NM.Client.new_async(self.cancellable, self.on_client_ready, None)
|
||||
|
||||
def on_client_ready(self, source, result, data):
|
||||
try:
|
||||
self.client = NM.Client.new_finish(result)
|
||||
except GLib.Error:
|
||||
self.status("Cannot contact NetworkManager. Check that the NetworkManager service is running.")
|
||||
return
|
||||
if self.closed:
|
||||
return
|
||||
self.bus = self.client.get_dbus_connection()
|
||||
self.guard = PromptGuard(self.bus, self.changed, self.status)
|
||||
self.secretObjectId = self.bus.register_object(
|
||||
SECRET_PATH, Gio.DBusNodeInfo.new_for_xml(SECRET_XML).interfaces[0], self.handle_secret, None, None)
|
||||
self.client.connect("notify", self.on_notify)
|
||||
self.register_agent()
|
||||
self.changed()
|
||||
|
||||
def register_agent(self):
|
||||
self.agentReady = False
|
||||
owner = self.client.get_dbus_name_owner()
|
||||
if not owner:
|
||||
return
|
||||
self.bus.call(owner, NM_PATH + "/AgentManager", NM_NAME + ".AgentManager", "RegisterWithCapabilities",
|
||||
GLib.Variant("(su)", ("org.stormux.I38.Network", 0)), None,
|
||||
Gio.DBusCallFlags.NONE, 10000, self.cancellable, self.on_agent_registered, owner)
|
||||
|
||||
def on_agent_registered(self, bus, result, owner):
|
||||
try:
|
||||
bus.call_finish(result)
|
||||
self.agentReady = not self.closed and self.client.get_dbus_name_owner() == owner
|
||||
except GLib.Error:
|
||||
self.status("Cannot register I38's Wi-Fi password dialog. Connecting is disabled.")
|
||||
self.changed()
|
||||
|
||||
def on_notify(self, obj, prop):
|
||||
if self.closed:
|
||||
return
|
||||
if prop.name == "dbus-name-owner":
|
||||
self.finish_operation("NetworkManager restarted. Review the connection state before trying again.")
|
||||
self.register_agent()
|
||||
self.schedule_refresh()
|
||||
|
||||
def schedule_refresh(self, *args):
|
||||
if not self.closed and not self.refreshSource:
|
||||
self.refreshSource = GLib.timeout_add(250, self.flush_refresh)
|
||||
|
||||
def flush_refresh(self):
|
||||
self.refreshSource = 0
|
||||
self.changed()
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
def permission_problem(self, permissions):
|
||||
if not self.client or not self.client.get_nm_running():
|
||||
return "NetworkManager is not running."
|
||||
if not self.member:
|
||||
return ("Managing connections requires membership in the network group on Stormux. "
|
||||
"Ask your administrator to add your account, then log out and back in.")
|
||||
if not self.guard or not self.guard.ready:
|
||||
if self.guard and self.guard.error:
|
||||
return "Connection changes are disabled: " + self.guard.error
|
||||
return "Connection changes are disabled until protection against administrator prompts is ready."
|
||||
if self.client.get_permissions_state() != NM.Ternary.TRUE:
|
||||
return "Waiting for NetworkManager permissions."
|
||||
for permission in permissions:
|
||||
result = self.client.get_permission_result(permission)
|
||||
if result != NM.ClientPermissionResult.YES:
|
||||
action = permission.value_nick.replace("-", " ")
|
||||
if result == NM.ClientPermissionResult.AUTH:
|
||||
return "System policy requires administrator authorization for {}. Ask your administrator to check the network group policy.".format(action)
|
||||
if result == NM.ClientPermissionResult.UNKNOWN:
|
||||
return "NetworkManager has not provided permission for {}.".format(action)
|
||||
return "System policy denies {} for this account.".format(action)
|
||||
return ""
|
||||
|
||||
def authorize(self, permissions):
|
||||
problem = self.permission_problem(permissions)
|
||||
if problem:
|
||||
self.status(problem)
|
||||
return False
|
||||
return not self.closed
|
||||
|
||||
def modification_permission(self, profile):
|
||||
setting = profile.get_setting_connection()
|
||||
return MODIFY_OWN if setting.get_num_permissions() else MODIFY_SYSTEM
|
||||
|
||||
def rows(self):
|
||||
for obj, handler in self.signalHandlers:
|
||||
GObject.signal_handler_disconnect(obj, handler)
|
||||
self.signalHandlers = []
|
||||
if not self.client or not self.client.get_nm_running():
|
||||
return []
|
||||
rows = []
|
||||
for device in self.client.get_devices():
|
||||
if device.get_device_type() not in (NM.DeviceType.WIFI, NM.DeviceType.ETHERNET):
|
||||
continue
|
||||
self.watch(device)
|
||||
# filter_connections() has a documented GI ownership bug. Validate
|
||||
# individually so Python never unrefs NetworkManager's profiles.
|
||||
profiles = [p for p in self.client.get_connections() if device.connection_valid(p)]
|
||||
for profile in profiles:
|
||||
self.watch(profile)
|
||||
active = device.get_active_connection()
|
||||
activeProfile = active.get_connection() if active else None
|
||||
if active:
|
||||
self.watch(active)
|
||||
if device.get_device_type() == NM.DeviceType.WIFI:
|
||||
groups = {}
|
||||
activeAp = device.get_active_access_point()
|
||||
for ap in device.get_access_points():
|
||||
self.watch(ap)
|
||||
ssid = ap.get_ssid()
|
||||
if not ssid or not ssid.get_data():
|
||||
continue
|
||||
groupKey = (bytes(ssid.get_data()), security_type(ap)[1], security_type(ap)[0])
|
||||
previous = groups.get(groupKey)
|
||||
if previous is None or ap == activeAp or (previous != activeAp and ap.get_strength() > previous.get_strength()):
|
||||
groups[groupKey] = ap
|
||||
represented = set()
|
||||
for groupKey, ap in groups.items():
|
||||
matches = [p for p in profiles if ap.connection_valid(p)]
|
||||
if activeProfile in matches:
|
||||
matches.remove(activeProfile)
|
||||
matches.insert(0, activeProfile)
|
||||
# Keep distinct saved profiles selectable even for identical SSIDs.
|
||||
for profile in matches or [None]:
|
||||
if profile:
|
||||
represented.add(profile.get_uuid())
|
||||
connected = active if profile and activeProfile == profile else None
|
||||
rows.append(self.make_row(device, profile, ap, connected))
|
||||
for profile in profiles:
|
||||
if profile.get_uuid() not in represented:
|
||||
rows.append(self.make_row(device, profile, None, active if profile == activeProfile else None))
|
||||
else:
|
||||
for profile in profiles:
|
||||
rows.append(self.make_row(device, profile, None, active if profile == activeProfile else None))
|
||||
return sorted(rows, key=lambda row: (row.name.casefold(), row.kind, row.key))
|
||||
|
||||
def watch(self, obj):
|
||||
self.signalHandlers.append((obj, obj.connect("notify", self.schedule_refresh)))
|
||||
|
||||
def make_row(self, device, profile, ap, active):
|
||||
wifi = device.get_device_type() == NM.DeviceType.WIFI
|
||||
name = profile.get_id() if profile else NM.utils_ssid_to_utf8(ap.get_ssid().get_data())
|
||||
key = (device.get_path(), profile.get_uuid() if profile else bytes(ap.get_ssid().get_data()).hex() + ":" + security_type(ap)[0])
|
||||
state = "Connected" if active and active.get_state() == NM.ActiveConnectionState.ACTIVATED else "Disconnected"
|
||||
if active and active.get_state() == NM.ActiveConnectionState.ACTIVATING:
|
||||
state = "Connecting"
|
||||
elif active and active.get_state() == NM.ActiveConnectionState.DEACTIVATING:
|
||||
state = "Disconnecting"
|
||||
elif wifi and not ap and not active:
|
||||
state = "Unavailable"
|
||||
elif not device.get_managed():
|
||||
state = "Unmanaged"
|
||||
elif wifi and not self.client.wireless_hardware_get_enabled():
|
||||
state = "Hardware blocked"
|
||||
elif wifi and not self.client.wireless_get_enabled():
|
||||
state = "Wi-Fi disabled"
|
||||
elif not wifi and not device.get_carrier():
|
||||
state = "Cable unplugged"
|
||||
security = security_type(ap)[0] if ap else ""
|
||||
return NetworkRow(key, name, "Wi-Fi" if wifi else "Ethernet", state,
|
||||
"{}%".format(ap.get_strength()) if ap else "", security,
|
||||
device, profile, ap, active)
|
||||
|
||||
def connect_row(self, row, password="", remember=True):
|
||||
permissions = [NETWORK_CONTROL] + ([] if row.profile else [MODIFY_SYSTEM])
|
||||
if self.busy or not self.authorize(permissions):
|
||||
return
|
||||
if not self.agentReady:
|
||||
self.status("The Wi-Fi password dialog is not ready. Try again shortly.")
|
||||
return
|
||||
try:
|
||||
connection = row.profile or new_wifi_connection(row, password, remember)
|
||||
except (ValueError, GLib.Error) as error:
|
||||
self.status(str(error))
|
||||
return
|
||||
self.busy = True
|
||||
self.pending = {"uuid": connection.get_uuid(), "profile": row.profile, "device": row.device,
|
||||
"active": None, "name": row.name}
|
||||
self.status("Connecting to {}…".format(row.name))
|
||||
if row.profile:
|
||||
self.client.activate_connection_async(connection, row.device, row.ap.get_path() if row.ap else None,
|
||||
self.cancellable, self.on_activated, False)
|
||||
else:
|
||||
self.client.add_and_activate_connection_async(connection, row.device, row.ap.get_path(),
|
||||
self.cancellable, self.on_activated, True)
|
||||
self.changed()
|
||||
|
||||
def on_activated(self, client, result, isNew):
|
||||
try:
|
||||
active = client.add_and_activate_connection_finish(result) if isNew else client.activate_connection_finish(result)
|
||||
except GLib.Error:
|
||||
self.finish_operation("Connection could not be started. Check permissions, availability, and the Wi-Fi password.")
|
||||
return
|
||||
if not self.pending or self.closed:
|
||||
return
|
||||
self.pending["active"] = active
|
||||
active.connect("state-changed", self.on_activation_state)
|
||||
self.activationTimeout = GLib.timeout_add_seconds(120, self.on_activation_timeout)
|
||||
self.check_activation(active)
|
||||
|
||||
def on_activation_state(self, active, state, reason):
|
||||
self.check_activation(active, reason)
|
||||
|
||||
def check_activation(self, active, reason=0):
|
||||
if not self.pending or self.pending["active"] != active:
|
||||
return
|
||||
state = active.get_state()
|
||||
if state == NM.ActiveConnectionState.ACTIVATED:
|
||||
self.finish_operation("Connected to {}.".format(self.pending["name"]))
|
||||
elif state == NM.ActiveConnectionState.DEACTIVATED:
|
||||
detail = reason.value_nick.replace("-", " ") if hasattr(reason, "value_nick") else "unknown reason"
|
||||
self.finish_operation("Connection failed or was cancelled: {}. Check the password and network availability.".format(detail))
|
||||
|
||||
def on_activation_timeout(self):
|
||||
self.activationTimeout = 0
|
||||
self.finish_operation("Connection is taking longer than expected. NetworkManager may still be trying; review its current state.")
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
def finish_operation(self, message):
|
||||
if getattr(self, "activationTimeout", 0):
|
||||
GLib.source_remove(self.activationTimeout)
|
||||
self.activationTimeout = 0
|
||||
self.cancel_secret()
|
||||
self.pending = None
|
||||
self.busy = False
|
||||
if not self.closed:
|
||||
self.status(message)
|
||||
self.changed()
|
||||
|
||||
def handle_secret(self, bus, sender, path, interface, method, parameters, invocation):
|
||||
if sender != self.client.get_dbus_name_owner():
|
||||
invocation.return_dbus_error(SECRET_INTERFACE + ".PermissionDenied", "Unexpected sender")
|
||||
return
|
||||
args = parameters.unpack()
|
||||
if method == "CancelGetSecrets":
|
||||
if self.secretRequest and self.secretRequest[0] == args[0]:
|
||||
self.cancel_secret()
|
||||
invocation.return_value(GLib.Variant("()", ()))
|
||||
return
|
||||
if method in ("SaveSecrets", "DeleteSecrets"):
|
||||
# This agent never owns or stores passwords. NetworkManager owns saved secrets.
|
||||
invocation.return_value(GLib.Variant("()", ()))
|
||||
return
|
||||
settings, connectionPath, settingName, hints, flags = args
|
||||
connectionId = settings.get("connection", {}).get("uuid")
|
||||
keyMgmt = settings.get(SECURITY_SETTING, {}).get("key-mgmt")
|
||||
if (not self.pending or connectionId != self.pending["uuid"] or
|
||||
settingName != SECURITY_SETTING or keyMgmt not in ("wpa-psk", "sae") or
|
||||
not flags & int(NM.SecretAgentGetSecretsFlags.ALLOW_INTERACTION)):
|
||||
invocation.return_dbus_error(SECRET_INTERFACE + ".NoSecrets", "No credentials available for this request")
|
||||
return
|
||||
self.cancel_secret()
|
||||
self.secretRequest = (connectionPath, invocation)
|
||||
profile = self.client.get_connection_by_uuid(connectionId)
|
||||
canRemember = bool(profile and not self.permission_problem([self.modification_permission(profile)]))
|
||||
|
||||
def answered(password, remember):
|
||||
if not self.secretRequest or self.secretRequest[1] != invocation:
|
||||
return
|
||||
if password is None:
|
||||
self.cancel_secret()
|
||||
return
|
||||
if not valid_password(password, keyMgmt):
|
||||
self.cancel_secret()
|
||||
self.status("The Wi-Fi password has an invalid length or format.")
|
||||
return
|
||||
if remember and profile:
|
||||
if not self.authorize([self.modification_permission(profile)]):
|
||||
self.cancel_secret()
|
||||
return
|
||||
clone = NM.SimpleConnection.new_clone(profile)
|
||||
security = clone.get_setting_wireless_security()
|
||||
security.props.psk = password
|
||||
security.props.psk_flags = NM.SettingSecretFlags.NONE
|
||||
profile.update2(clone.to_dbus(NM.ConnectionSerializationFlags.ALL), NM.SettingsUpdate2Flags.TO_DISK,
|
||||
GLib.Variant("a{sv}", {}), self.cancellable, saved, password)
|
||||
else:
|
||||
reply(password)
|
||||
|
||||
def saved(profile, result, password):
|
||||
try:
|
||||
profile.update2_finish(result)
|
||||
except GLib.Error:
|
||||
self.cancel_secret()
|
||||
self.status("The password could not be saved. Check your connection permissions.")
|
||||
return
|
||||
reply(password)
|
||||
|
||||
def reply(password):
|
||||
if self.secretRequest and self.secretRequest[1] == invocation:
|
||||
self.secretRequest = None
|
||||
invocation.return_value(GLib.Variant("(a{sa{sv}})", ({SECURITY_SETTING: {"psk": GLib.Variant("s", password)}},)))
|
||||
|
||||
self.password(self.pending["name"], keyMgmt, canRemember, answered)
|
||||
|
||||
def cancel_secret(self):
|
||||
if self.secretRequest:
|
||||
request = self.secretRequest
|
||||
self.secretRequest = None
|
||||
request[1].return_dbus_error(SECRET_INTERFACE + ".UserCanceled", "Password entry cancelled")
|
||||
|
||||
def disconnect_row(self, row):
|
||||
if self.busy or not row.active or not self.authorize([NETWORK_CONTROL]):
|
||||
return
|
||||
self.busy = True
|
||||
self.client.deactivate_connection_async(row.active, self.cancellable, self.on_disconnected, None)
|
||||
self.changed()
|
||||
|
||||
def on_disconnected(self, client, result, data):
|
||||
try:
|
||||
client.deactivate_connection_finish(result)
|
||||
self.finish_operation("Disconnected. NetworkManager may automatically activate another saved connection.")
|
||||
except GLib.Error:
|
||||
self.finish_operation("Could not disconnect. Check your network permissions.")
|
||||
|
||||
def set_wireless(self, enabled):
|
||||
if self.busy or not self.authorize([NM.ClientPermission.ENABLE_DISABLE_WIFI]):
|
||||
return
|
||||
self.busy = True
|
||||
self.bus.call(self.client.get_dbus_name_owner(), NM_PATH, "org.freedesktop.DBus.Properties", "Set",
|
||||
GLib.Variant("(ssv)", (NM_NAME, "WirelessEnabled", GLib.Variant("b", enabled))), None,
|
||||
Gio.DBusCallFlags.NONE, 10000, self.cancellable, self.on_wireless_set, enabled)
|
||||
self.changed()
|
||||
|
||||
def on_wireless_set(self, bus, result, enabled):
|
||||
try:
|
||||
bus.call_finish(result)
|
||||
self.finish_operation("Wi-Fi enabled." if enabled else "Wi-Fi disabled.")
|
||||
except GLib.Error:
|
||||
self.finish_operation("Could not change Wi-Fi state. Check permissions and the hardware wireless switch.")
|
||||
|
||||
def scan(self):
|
||||
if not self.authorize([NM.ClientPermission.WIFI_SCAN]):
|
||||
return
|
||||
devices = [d for d in self.client.get_devices() if isinstance(d, NM.DeviceWifi) and d.get_managed()]
|
||||
if not devices or not self.client.wireless_get_enabled():
|
||||
self.status("No enabled Wi-Fi adapter is available.")
|
||||
return
|
||||
self.status("Refreshing Wi-Fi networks…")
|
||||
for device in devices:
|
||||
device.request_scan_async(self.cancellable, self.on_scanned, None)
|
||||
|
||||
def on_scanned(self, device, result, data):
|
||||
try:
|
||||
device.request_scan_finish(result)
|
||||
self.status("Wi-Fi scan requested. The list will update as results arrive.")
|
||||
except GLib.Error:
|
||||
self.status("Wi-Fi scan could not be requested. Try again shortly.")
|
||||
self.changed()
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
self.cancellable.cancel()
|
||||
self.cancel_secret()
|
||||
if self.refreshSource:
|
||||
GLib.source_remove(self.refreshSource)
|
||||
if getattr(self, "activationTimeout", 0):
|
||||
GLib.source_remove(self.activationTimeout)
|
||||
for obj, handler in self.signalHandlers:
|
||||
GObject.signal_handler_disconnect(obj, handler)
|
||||
if self.client and hasattr(self, "secretObjectId"):
|
||||
owner = self.client.get_dbus_name_owner()
|
||||
if owner:
|
||||
self.bus.call(owner, NM_PATH + "/AgentManager", NM_NAME + ".AgentManager", "Unregister", None,
|
||||
None, Gio.DBusCallFlags.NONE, 5000, None, None, None)
|
||||
self.bus.unregister_object(self.secretObjectId)
|
||||
if self.guard:
|
||||
self.guard.close()
|
||||
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is part of I38, licensed under the GNU GPL, version 3 or later.
|
||||
|
||||
import argparse
|
||||
import threading
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Atk", "1.0")
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
|
||||
from shortcuts import (clipboard_target, create_shortcut, detect_wine_prefix, fetch_page_title,
|
||||
installed_applications, parse_target, validate_wine_prefix)
|
||||
|
||||
|
||||
class ApplicationDialog(Gtk.Dialog):
|
||||
def __init__(self, parent):
|
||||
super().__init__(title="Search for application", transient_for=parent, modal=True)
|
||||
self.set_default_size(620, 420)
|
||||
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "S_elect", Gtk.ResponseType.OK)
|
||||
self.set_default_response(Gtk.ResponseType.OK)
|
||||
box = self.get_content_area()
|
||||
box.set_spacing(8)
|
||||
box.set_border_width(12)
|
||||
label = Gtk.Label.new_with_mnemonic("_Search applications:")
|
||||
label.set_xalign(0)
|
||||
self.searchEntry = Gtk.SearchEntry()
|
||||
label.set_mnemonic_widget(self.searchEntry)
|
||||
self.searchEntry.get_accessible().set_name("Search applications")
|
||||
box.pack_start(label, False, False, 0)
|
||||
box.pack_start(self.searchEntry, False, False, 0)
|
||||
self.store = Gtk.ListStore(str, str, str)
|
||||
for app in installed_applications():
|
||||
self.store.append((app.get_display_name(), app.get_id(), app.get_filename()))
|
||||
self.query = ""
|
||||
self.filtered = self.store.filter_new()
|
||||
self.filtered.set_visible_func(lambda model, row, _data:
|
||||
model[row][0].casefold().startswith(self.query))
|
||||
self.view = Gtk.TreeView(model=self.filtered)
|
||||
self.view.set_enable_search(False)
|
||||
self.view.get_accessible().set_name("Applications")
|
||||
for index, title in enumerate(("Application", "Launcher")):
|
||||
self.view.append_column(Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=index))
|
||||
self.view.connect("row-activated", self.confirm_selection)
|
||||
self.view.connect("key-press-event", self.on_list_key)
|
||||
scroll = Gtk.ScrolledWindow()
|
||||
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
scroll.add(self.view)
|
||||
box.pack_start(scroll, True, True, 0)
|
||||
self.countLabel = Gtk.Label(xalign=0)
|
||||
box.pack_start(self.countLabel, False, False, 0)
|
||||
self.searchEntry.connect("changed", self.on_search)
|
||||
self.searchEntry.connect("activate", self.confirm_selection)
|
||||
self.searchEntry.connect("key-press-event", self.on_search_key)
|
||||
self.view.get_selection().connect("changed", self.on_selection)
|
||||
self.on_search(self.searchEntry)
|
||||
self.show_all()
|
||||
self.searchEntry.grab_focus()
|
||||
|
||||
def on_search(self, entry):
|
||||
self.query = entry.get_text().casefold()
|
||||
self.filtered.refilter()
|
||||
if len(self.filtered):
|
||||
self.view.set_cursor(Gtk.TreePath.new_first())
|
||||
self.countLabel.set_text("{} applications found.".format(len(self.filtered)))
|
||||
self.on_selection(self.view.get_selection())
|
||||
|
||||
def on_selection(self, selection):
|
||||
_model, row = selection.get_selected()
|
||||
self.set_response_sensitive(Gtk.ResponseType.OK, row is not None)
|
||||
|
||||
def on_search_key(self, _entry, event):
|
||||
if event.keyval == Gdk.KEY_Down:
|
||||
self.view.grab_focus()
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_list_key(self, _view, event):
|
||||
character = Gdk.keyval_to_unicode(event.keyval)
|
||||
if character >= 32 and not event.state & (Gdk.ModifierType.CONTROL_MASK |
|
||||
Gdk.ModifierType.MOD1_MASK |
|
||||
Gdk.ModifierType.SUPER_MASK):
|
||||
self.searchEntry.grab_focus()
|
||||
self.searchEntry.insert_text(chr(character), -1)
|
||||
self.searchEntry.set_position(-1)
|
||||
return True
|
||||
if event.keyval == Gdk.KEY_BackSpace:
|
||||
self.searchEntry.set_text(self.searchEntry.get_text()[:-1])
|
||||
self.searchEntry.grab_focus()
|
||||
self.searchEntry.set_position(-1)
|
||||
return True
|
||||
return False
|
||||
|
||||
def selected_path(self):
|
||||
model, row = self.view.get_selection().get_selected()
|
||||
return model[row][2] if row is not None else None
|
||||
|
||||
def confirm_selection(self, *_args):
|
||||
if self.selected_path() is not None:
|
||||
self.response(Gtk.ResponseType.OK)
|
||||
|
||||
|
||||
class ShortcutWindow(Gtk.Window):
|
||||
def __init__(self, fileManager=""):
|
||||
super().__init__(title="I38 Create Desktop Shortcut")
|
||||
self.fileManager = fileManager
|
||||
self.target = None
|
||||
self.requestNumber = 0
|
||||
self.nameVersion = 0
|
||||
self.closed = False
|
||||
self.set_default_size(650, 300)
|
||||
self.set_border_width(12)
|
||||
self.connect("destroy", Gtk.main_quit)
|
||||
self.connect("destroy", self.on_destroy)
|
||||
self.connect("key-press-event", self.on_key)
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
self.add(box)
|
||||
instructions = Gtk.Label(label="Choose a source, then review the target and shortcut name.", xalign=0)
|
||||
box.pack_start(instructions, False, False, 0)
|
||||
for label, callback in (("Add from _Clipboard", self.on_clipboard),
|
||||
("Browse for _File", self.on_file),
|
||||
("Browse for F_older", self.on_folder),
|
||||
("Search for _Application", self.on_application)):
|
||||
button = Gtk.Button.new_with_mnemonic(label)
|
||||
button.connect("clicked", callback)
|
||||
box.pack_start(button, False, False, 0)
|
||||
self.targetEntry = Gtk.Entry()
|
||||
self.targetEntry.set_placeholder_text("/usr/bin/firefox, ~/Documents, or https://example.org/")
|
||||
self.add_field(box, "_Target (path, program, or web URL):", self.targetEntry)
|
||||
self.nameEntry = Gtk.Entry()
|
||||
self.add_field(box, "Shortcut _name:", self.nameEntry)
|
||||
self.wineBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
self.wineBox.set_no_show_all(True)
|
||||
self.wineEntry = Gtk.Entry()
|
||||
self.wineEntry.set_activates_default(True)
|
||||
self.add_field(self.wineBox, "_Wine prefix (bottle) folder:", self.wineEntry)
|
||||
wineButton = Gtk.Button.new_with_mnemonic("_Browse for Wine Prefix")
|
||||
wineButton.connect("clicked", self.on_wine_prefix)
|
||||
self.wineBox.pack_start(wineButton, False, False, 0)
|
||||
box.pack_start(self.wineBox, False, False, 0)
|
||||
self.targetEntry.connect("changed", self.on_target_changed)
|
||||
self.targetEntry.connect("activate", self.on_target_activate)
|
||||
self.nameEntry.connect("changed", self.on_name_changed)
|
||||
self.nameEntry.set_activates_default(True)
|
||||
self.terminalCheck = Gtk.CheckButton.new_with_mnemonic("Run program in a ter_minal")
|
||||
self.terminalCheck.set_sensitive(False)
|
||||
box.pack_start(self.terminalCheck, False, False, 0)
|
||||
self.statusLabel = Gtk.Label(label="No source selected.", xalign=0)
|
||||
self.statusLabel.set_line_wrap(True)
|
||||
self.statusLabel.set_selectable(True)
|
||||
self.statusLabel.get_accessible().set_name("Shortcut status")
|
||||
box.pack_start(self.statusLabel, False, False, 0)
|
||||
buttons = Gtk.ButtonBox(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
buttons.set_layout(Gtk.ButtonBoxStyle.END)
|
||||
closeButton = Gtk.Button.new_with_mnemonic("C_lose")
|
||||
closeButton.connect("clicked", lambda *_args: self.close())
|
||||
self.createButton = Gtk.Button.new_with_mnemonic("C_reate Shortcut")
|
||||
self.createButton.set_can_default(True)
|
||||
self.createButton.connect("clicked", self.on_create)
|
||||
self.createButton.set_sensitive(False)
|
||||
buttons.add(closeButton)
|
||||
buttons.add(self.createButton)
|
||||
box.pack_start(buttons, False, False, 0)
|
||||
self.set_default(self.createButton)
|
||||
self.show_all()
|
||||
box.get_children()[1].grab_focus()
|
||||
|
||||
def add_field(self, box, text, entry):
|
||||
label = Gtk.Label.new_with_mnemonic(text)
|
||||
label.set_xalign(0)
|
||||
label.set_mnemonic_widget(entry)
|
||||
box.pack_start(label, False, False, 0)
|
||||
box.pack_start(entry, False, False, 0)
|
||||
|
||||
def show_message(self, message, error=False):
|
||||
dialog = Gtk.MessageDialog(transient_for=self, modal=True,
|
||||
message_type=Gtk.MessageType.ERROR if error else Gtk.MessageType.INFO,
|
||||
buttons=Gtk.ButtonsType.CLOSE,
|
||||
text="Could not create shortcut" if error else "Desktop shortcut created")
|
||||
dialog.format_secondary_text(message)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
def on_target_changed(self, _entry):
|
||||
self.requestNumber += 1
|
||||
self.target = None
|
||||
self.createButton.set_sensitive(False)
|
||||
self.terminalCheck.set_active(False)
|
||||
self.terminalCheck.set_sensitive(False)
|
||||
self.wineBox.hide()
|
||||
self.wineEntry.set_text("")
|
||||
self.statusLabel.set_text("Press Enter to check the target, or choose a source above.")
|
||||
|
||||
def on_name_changed(self, _entry):
|
||||
self.nameVersion += 1
|
||||
self.createButton.set_sensitive(self.target is not None and bool(self.nameEntry.get_text().strip()))
|
||||
|
||||
def set_target(self, target):
|
||||
self.targetEntry.set_text(target.value)
|
||||
self.target = target
|
||||
self.nameEntry.set_text(target.name)
|
||||
self.terminalCheck.set_sensitive(target.kind in ("Program", "Windows program"))
|
||||
self.statusLabel.set_text("{}: {}".format(target.kind, target.value))
|
||||
self.nameEntry.grab_focus()
|
||||
self.nameEntry.select_region(0, -1)
|
||||
if target.kind == "Windows program":
|
||||
winePrefix = detect_wine_prefix(target.value)
|
||||
self.wineEntry.set_text(winePrefix or "")
|
||||
self.wineBox.set_no_show_all(False)
|
||||
self.wineBox.show_all()
|
||||
self.wineBox.set_no_show_all(True)
|
||||
if winePrefix:
|
||||
self.statusLabel.set_text("Windows program: {}. Wine prefix: {}".format(target.value, winePrefix))
|
||||
else:
|
||||
self.statusLabel.set_text("Choose the Wine prefix (bottle) folder before creating this shortcut.")
|
||||
self.wineEntry.grab_focus()
|
||||
if target.kind == "Web link":
|
||||
requestNumber = self.requestNumber
|
||||
nameVersion = self.nameVersion
|
||||
self.statusLabel.set_text("Looking up page title. You can edit the name or create the shortcut now.")
|
||||
|
||||
def lookup():
|
||||
try:
|
||||
title = fetch_page_title(target.value)
|
||||
except Exception:
|
||||
# Network, TLS, decoding, and malformed HTML failures all
|
||||
# leave the usable hostname suggestion in place.
|
||||
title = None
|
||||
GLib.idle_add(self.apply_title, requestNumber, nameVersion, target, title)
|
||||
|
||||
threading.Thread(target=lookup, daemon=True).start()
|
||||
|
||||
def apply_title(self, requestNumber, nameVersion, target, title):
|
||||
if self.closed or requestNumber != self.requestNumber:
|
||||
return False
|
||||
if nameVersion == self.nameVersion and title:
|
||||
self.nameEntry.set_text(title)
|
||||
self.nameEntry.select_region(0, -1)
|
||||
self.statusLabel.set_text("Page title found." if title else
|
||||
"Page title unavailable. Using the site hostname unless you edit the name.")
|
||||
return False
|
||||
|
||||
def on_destroy(self, _widget):
|
||||
self.closed = True
|
||||
self.requestNumber += 1
|
||||
|
||||
def on_target_activate(self, _entry):
|
||||
try:
|
||||
self.set_target(parse_target(self.targetEntry.get_text()))
|
||||
except (ValueError, OSError, GLib.Error) as error:
|
||||
self.show_message(str(error), error=True)
|
||||
|
||||
def on_clipboard(self, _button):
|
||||
self.requestNumber += 1
|
||||
requestNumber = self.requestNumber
|
||||
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||
self.statusLabel.set_text("Reading clipboard…")
|
||||
|
||||
def received_text(_clipboard, text, _data):
|
||||
if requestNumber != self.requestNumber:
|
||||
return
|
||||
try:
|
||||
self.set_target(clipboard_target(text or "", "text"))
|
||||
except (ValueError, UnicodeError, OSError, GLib.Error) as error:
|
||||
self.show_message(str(error), error=True)
|
||||
|
||||
def received_contents(_clipboard, selection, targetType):
|
||||
if requestNumber != self.requestNumber:
|
||||
return
|
||||
try:
|
||||
self.set_target(clipboard_target(selection.get_data() or b"", targetType))
|
||||
except (ValueError, UnicodeError, OSError, GLib.Error) as error:
|
||||
self.show_message(str(error), error=True)
|
||||
|
||||
def received_targets(_clipboard, targets, _targetCount, _data):
|
||||
if requestNumber != self.requestNumber:
|
||||
return
|
||||
names = {atom.name(): atom for atom in targets or []}
|
||||
for targetType in ("x-special/gnome-copied-files", "text/uri-list", "application/x-kde4-urilist"):
|
||||
if targetType in names:
|
||||
clipboard.request_contents(names[targetType], received_contents, targetType)
|
||||
return
|
||||
clipboard.request_text(received_text, None)
|
||||
|
||||
clipboard.request_targets(received_targets, None)
|
||||
|
||||
def choose_path(self, folder):
|
||||
self.requestNumber += 1
|
||||
dialog = Gtk.FileChooserDialog(title="Choose folder" if folder else "Choose file or program",
|
||||
transient_for=self,
|
||||
action=Gtk.FileChooserAction.SELECT_FOLDER if folder else Gtk.FileChooserAction.OPEN)
|
||||
dialog.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Select", Gtk.ResponseType.OK)
|
||||
dialog.set_local_only(True)
|
||||
if dialog.run() == Gtk.ResponseType.OK:
|
||||
value = dialog.get_filename()
|
||||
else:
|
||||
value = None
|
||||
dialog.destroy()
|
||||
if value:
|
||||
try:
|
||||
self.set_target(parse_target(value))
|
||||
except (ValueError, OSError, GLib.Error) as error:
|
||||
self.show_message(str(error), error=True)
|
||||
|
||||
def on_file(self, _button):
|
||||
self.choose_path(False)
|
||||
|
||||
def on_folder(self, _button):
|
||||
self.choose_path(True)
|
||||
|
||||
def on_wine_prefix(self, _button):
|
||||
dialog = Gtk.FileChooserDialog(title="Choose Wine prefix (bottle) folder",
|
||||
transient_for=self, modal=True,
|
||||
action=Gtk.FileChooserAction.SELECT_FOLDER)
|
||||
dialog.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Select", Gtk.ResponseType.OK)
|
||||
dialog.set_local_only(True)
|
||||
dialog.set_show_hidden(True)
|
||||
if dialog.run() == Gtk.ResponseType.OK:
|
||||
self.wineEntry.set_text(dialog.get_filename())
|
||||
dialog.destroy()
|
||||
self.wineEntry.grab_focus()
|
||||
|
||||
def on_application(self, _button):
|
||||
self.requestNumber += 1
|
||||
try:
|
||||
dialog = ApplicationDialog(self)
|
||||
value = dialog.selected_path() if dialog.run() == Gtk.ResponseType.OK else None
|
||||
dialog.destroy()
|
||||
if value:
|
||||
self.set_target(parse_target(value))
|
||||
except (ValueError, OSError, GLib.Error) as error:
|
||||
self.show_message(str(error), error=True)
|
||||
|
||||
def on_create(self, _button):
|
||||
if self.target is None:
|
||||
return
|
||||
winePrefix = None
|
||||
if self.target.kind == "Windows program":
|
||||
try:
|
||||
winePrefix = validate_wine_prefix(self.wineEntry.get_text())
|
||||
except ValueError as error:
|
||||
self.show_message(str(error), error=True)
|
||||
self.wineEntry.grab_focus()
|
||||
return
|
||||
try:
|
||||
destination = create_shortcut(self.target, self.nameEntry.get_text(), self.fileManager,
|
||||
self.terminalCheck.get_active(), winePrefix=winePrefix)
|
||||
except (ValueError, OSError, GLib.Error) as error:
|
||||
self.show_message(str(error), error=True)
|
||||
return
|
||||
self.show_message("Created {}.\n\nOpen I38 Desktop to use it.".format(destination))
|
||||
self.close()
|
||||
|
||||
def on_key(self, _widget, event):
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
self.close()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Create an I38 desktop shortcut")
|
||||
parser.add_argument("--file-manager", default="", help="I38's selected file manager executable")
|
||||
args = parser.parse_args()
|
||||
GLib.set_application_name("I38 Create Desktop Shortcut")
|
||||
ShortcutWindow(args.file_manager)
|
||||
Gtk.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is part of I38, licensed under the GNU GPL, version 3 or later.
|
||||
|
||||
"""Desktop shortcut creation. Targets are data, never shell commands."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from collections import namedtuple
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gio", "2.0")
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
try:
|
||||
gi.require_version("GioUnix", "2.0")
|
||||
from gi.repository import GioUnix
|
||||
DesktopAppInfo = GioUnix.DesktopAppInfo
|
||||
except (ImportError, ValueError):
|
||||
DesktopAppInfo = Gio.DesktopAppInfo
|
||||
|
||||
|
||||
Target = namedtuple("Target", "value name kind icon")
|
||||
|
||||
|
||||
class PageTitleParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.inTitle = False
|
||||
self.complete = False
|
||||
self.parts = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "title" and not self.complete:
|
||||
self.inTitle = True
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "title" and self.inTitle:
|
||||
self.inTitle = False
|
||||
self.complete = True
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.inTitle:
|
||||
self.parts.append(data)
|
||||
|
||||
|
||||
class WebRedirectHandler(HTTPRedirectHandler):
|
||||
def redirect_request(self, request, response, code, message, headers, newUrl):
|
||||
if urlsplit(newUrl).scheme.lower() not in ("http", "https"):
|
||||
raise ValueError("Unsupported web redirect")
|
||||
return super().redirect_request(request, response, code, message, headers, newUrl)
|
||||
|
||||
|
||||
def fetch_page_title(url):
|
||||
"""Fetch bounded public HTML without browser cookies or script execution.
|
||||
|
||||
The caller runs tihs off the GTK thread and keeps the hostname on failure.
|
||||
"""
|
||||
request = Request(url, headers={"User-Agent": "I38-Shortcut-Creator/1.0",
|
||||
"Accept": "text/html, application/xhtml+xml"})
|
||||
deadline = time.monotonic() + 8
|
||||
with build_opener(WebRedirectHandler()).open(request, timeout=3) as response:
|
||||
if response.headers.get_content_type() not in ("text/html", "application/xhtml+xml"):
|
||||
return None
|
||||
content = bytearray()
|
||||
while len(content) < 262144 and time.monotonic() < deadline:
|
||||
chunk = response.read1(min(16384, 262144 - len(content)))
|
||||
if not chunk:
|
||||
break
|
||||
content.extend(chunk)
|
||||
if b"</title" in content.lower():
|
||||
break
|
||||
encoding = response.headers.get_content_charset()
|
||||
if not encoding:
|
||||
match = re.search(br'<meta\b[^>]*charset\s*=\s*["\x27]?\s*([a-zA-Z0-9_-]+)', content[:8192], re.I)
|
||||
encoding = match.group(1).decode("ascii") if match else "utf-8"
|
||||
try:
|
||||
html = content.decode(encoding, errors="replace")
|
||||
except LookupError:
|
||||
html = content.decode("utf-8", errors="replace")
|
||||
parser = PageTitleParser()
|
||||
parser.feed(html)
|
||||
title = " ".join("".join(parser.parts).split())
|
||||
title = "".join(character for character in title if ord(character) >= 32 and ord(character) != 127)
|
||||
return title[:200] if parser.complete and title else None
|
||||
|
||||
|
||||
def desktop_directory():
|
||||
location = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DESKTOP)
|
||||
desktopPath = Path(location) if location else Path.home() / "Desktop"
|
||||
if desktopPath == Path.home():
|
||||
raise ValueError("The desktop directory is disabled. Configure XDG_DESKTOP_DIR first.")
|
||||
return desktopPath
|
||||
|
||||
|
||||
def parse_target(value):
|
||||
value = value.strip()
|
||||
if not value or any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||
raise ValueError("Enter one file, folder, program, or http/https URL.")
|
||||
parts = urlsplit(value) if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", value) else None
|
||||
if parts and parts.scheme.lower() in ("http", "https"):
|
||||
if not parts.hostname or any(character.isspace() for character in value):
|
||||
raise ValueError("Enter a complete web address, such as https://example.org/.")
|
||||
# Accessing port also validates malformed ports and IPv6 addresses.
|
||||
parts.port
|
||||
return Target(value, parts.hostname, "Web link", "web-browser")
|
||||
if parts and parts.scheme.lower() == "file":
|
||||
if parts.netloc not in ("", "localhost") or parts.query or parts.fragment:
|
||||
raise ValueError("Only local file URLs are supported.")
|
||||
value = unquote(parts.path)
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||
raise ValueError("File names containing control characters are not supported.")
|
||||
elif parts:
|
||||
raise ValueError("Use a local path or an http/https web address.")
|
||||
|
||||
targetPath = Path(value).expanduser()
|
||||
if not targetPath.is_absolute():
|
||||
command = shutil.which(value) if "/" not in value else None
|
||||
if command is None:
|
||||
raise ValueError("Use an absolute file path, ~/path, or a program name on PATH.")
|
||||
targetPath = Path(command)
|
||||
if not targetPath.exists():
|
||||
raise ValueError("That file or folder does not exist.")
|
||||
if targetPath.is_dir():
|
||||
return Target(str(targetPath), targetPath.name or "/", "Folder", "folder")
|
||||
if not targetPath.is_file():
|
||||
raise ValueError("Choose a regular file, folder, or program.")
|
||||
if targetPath.suffix.lower() == ".desktop":
|
||||
try:
|
||||
app = DesktopAppInfo.new_from_filename(str(targetPath))
|
||||
except (TypeError, GLib.Error):
|
||||
app = None
|
||||
if app is None:
|
||||
raise ValueError("That desktop launcher is invalid or its program is unavailable.")
|
||||
icon = app.get_icon()
|
||||
return Target(str(targetPath), app.get_display_name(), "Application",
|
||||
icon.to_string() if icon else "application-x-executable")
|
||||
if targetPath.suffix.lower() == ".exe":
|
||||
return Target(str(targetPath), targetPath.stem, "Windows program", "application-x-executable")
|
||||
if os.access(str(targetPath), os.X_OK):
|
||||
return Target(str(targetPath), targetPath.name, "Program", "application-x-executable")
|
||||
return Target(str(targetPath), targetPath.name, "File", "text-x-generic")
|
||||
|
||||
|
||||
def is_wine_prefix(path):
|
||||
return ((path / "drive_c").is_dir() and (path / "dosdevices").is_dir()
|
||||
and (path / "system.reg").is_file())
|
||||
|
||||
|
||||
def detect_wine_prefix(value):
|
||||
targetPath = Path(value).expanduser()
|
||||
# Check the supplied path first: drive_c itself can be a symlink. Then
|
||||
# resolve links so an executable linked onto the desktop also works.
|
||||
for programPath in (targetPath, targetPath.resolve()):
|
||||
for parent in programPath.parents:
|
||||
if is_wine_prefix(parent):
|
||||
return str(parent)
|
||||
defaultPrefix = Path.home() / ".wine"
|
||||
return str(defaultPrefix) if is_wine_prefix(defaultPrefix) else None
|
||||
|
||||
|
||||
def validate_wine_prefix(value):
|
||||
if not value or any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||
raise ValueError("Choose the Wine prefix (bottle) folder for this Windows program.")
|
||||
prefixPath = Path(value).expanduser()
|
||||
if not prefixPath.is_absolute() or not is_wine_prefix(prefixPath):
|
||||
raise ValueError("Choose an existing Wine prefix containing drive_c, dosdevices, and system.reg.")
|
||||
return str(prefixPath)
|
||||
|
||||
|
||||
def clipboard_target(data, targetType):
|
||||
if isinstance(data, bytes):
|
||||
data = data.decode("utf-8")
|
||||
lines = data.replace("\r\n", "\n").strip().splitlines()
|
||||
if targetType in ("x-special/gnome-copied-files", "application/x-kde4-urilist"):
|
||||
if lines and lines[0] in ("copy", "cut"):
|
||||
lines = lines[1:]
|
||||
if targetType != "text":
|
||||
lines = [line for line in lines if line and not line.startswith("#")]
|
||||
if len(lines) != 1:
|
||||
raise ValueError("Copy one file, folder, or URL at a time.")
|
||||
return parse_target(lines[0])
|
||||
|
||||
|
||||
def installed_applications():
|
||||
apps = []
|
||||
for app in Gio.AppInfo.get_all():
|
||||
if isinstance(app, DesktopAppInfo) and app.should_show() and app.get_filename():
|
||||
apps.append(app)
|
||||
return sorted(apps, key=lambda app: (app.get_display_name().casefold(), app.get_id()))
|
||||
|
||||
|
||||
def exec_argument(value):
|
||||
# Exec has its own quoting rules; GLib.KeyFile handles the outer string escaping.
|
||||
escaped = value.replace("%", "%%")
|
||||
for character in ("\\", '"', "`", "$"):
|
||||
escaped = escaped.replace(character, "\\" + character)
|
||||
return '"' + escaped + '"'
|
||||
|
||||
|
||||
def shortcut_data(target, name, fileManager="", terminal=False, winePrefix=None):
|
||||
name = name.strip()
|
||||
if not name or any(ord(character) < 32 or ord(character) == 127 for character in name):
|
||||
raise ValueError("Enter a shortcut name without line breaks or control characters.")
|
||||
# Validate again in case the target disappeared while the dialog was open.
|
||||
target = parse_target(target.value)
|
||||
if target.kind == "Folder":
|
||||
if not fileManager or not shutil.which(fileManager):
|
||||
raise ValueError("I38 has no available file manager configured. Select one in I38 setup.")
|
||||
arguments = [fileManager, target.value]
|
||||
elif target.kind == "Program":
|
||||
if "=" in target.value:
|
||||
raise ValueError("Desktop launchers cannot run a program with '=' in its path.")
|
||||
arguments = [target.value]
|
||||
elif target.kind == "Windows program":
|
||||
winePrefix = validate_wine_prefix(winePrefix if winePrefix is not None else
|
||||
detect_wine_prefix(target.value))
|
||||
if shutil.which("wine") is None:
|
||||
raise ValueError("Required program is unavailable: wine")
|
||||
arguments = ["env", "WINEPREFIX=" + winePrefix, "wine", target.value]
|
||||
elif target.kind == "Application":
|
||||
# Launch the original entry so D-Bus identity, field codes, terminal and
|
||||
# working-directory settings survive a renamed desktop shortcut.
|
||||
arguments = ["gio", "launch", target.value]
|
||||
else:
|
||||
arguments = ["xdg-open", target.value]
|
||||
if shutil.which(arguments[0]) is None:
|
||||
raise ValueError("Required program is unavailable: " + arguments[0])
|
||||
keyFile = GLib.KeyFile()
|
||||
for key, value in (("Type", "Application"), ("Name", name), ("Icon", target.icon),
|
||||
("Exec", " ".join(exec_argument(argument) for argument in arguments))):
|
||||
keyFile.set_string("Desktop Entry", key, value)
|
||||
keyFile.set_boolean("Desktop Entry", "Terminal", terminal and target.kind in ("Program", "Windows program"))
|
||||
if target.kind == "Windows program":
|
||||
keyFile.set_string("Desktop Entry", "Path", str(Path(target.value).resolve().parent))
|
||||
return keyFile.to_data()[0]
|
||||
|
||||
|
||||
def create_shortcut(target, name, fileManager="", terminal=False, desktopPath=None, winePrefix=None):
|
||||
data = shortcut_data(target, name, fileManager, terminal, winePrefix)
|
||||
desktopPath = Path(desktopPath) if desktopPath is not None else desktop_directory()
|
||||
desktopPath.mkdir(parents=True, exist_ok=True)
|
||||
# Keep long names below common NAME_MAX limits, including numbered suffixes.
|
||||
stem = re.sub(r"[/\\\x00-\x1f\x7f]", "_", name.strip()).lstrip(".")
|
||||
stem = stem.encode("utf-8")[:180].decode("utf-8", errors="ignore") or "Shortcut"
|
||||
tempName = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=str(desktopPath),
|
||||
prefix=".i38-shortcut-", delete=False) as stream:
|
||||
tempName = stream.name
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fchmod(stream.fileno(), 0o755)
|
||||
# Linking a complete temporary file publishes atomically without
|
||||
# overwriting existing files, directories, or even dangling symlinks.
|
||||
number = 1
|
||||
while True:
|
||||
suffix = "" if number == 1 else " ({})".format(number)
|
||||
destination = desktopPath / (stem + suffix + ".desktop")
|
||||
try:
|
||||
os.link(tempName, str(destination))
|
||||
return destination
|
||||
except FileExistsError:
|
||||
number += 1
|
||||
finally:
|
||||
if tempName is not None:
|
||||
os.unlink(tempName)
|
||||
@@ -97,7 +97,7 @@ if [ -n "$result" ]; then
|
||||
if [ "$selectedReader" = "orca" ]; then
|
||||
orca &
|
||||
else
|
||||
ORC_CODE=backup cthulhu &
|
||||
cthulhu &
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is part of I38, licensed under the GNU GPL, version 3 or later.
|
||||
|
||||
"""Announce USB hotplug events without claiming or configuring devices."""
|
||||
|
||||
import collections
|
||||
import fcntl
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
CONNECT_SOUND = (
|
||||
'play', '-q', '-r', '44100', '-c', '1', '-n',
|
||||
'synth', '0.65', 'triangle', '360:1190', 'tremolo', '14', '85',
|
||||
'fade', 'h', '0.01', '0.65', '0.1', 'gain', '-14',
|
||||
)
|
||||
|
||||
|
||||
def clean_name(value):
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode('utf-8', errors='replace')
|
||||
value = value or ''
|
||||
value = ''.join(char if char.isprintable() else ' ' for char in value)
|
||||
return ' '.join(value.replace('_', ' ').split())[:160]
|
||||
|
||||
|
||||
def read_attribute(device, name):
|
||||
try:
|
||||
return clean_name(device.attributes.get(name))
|
||||
except OSError:
|
||||
# Sysfs attributes can disappear before the removal event arrives.
|
||||
return ''
|
||||
|
||||
|
||||
def device_name(device):
|
||||
model = (clean_name(device.properties.get('ID_MODEL_FROM_DATABASE'))
|
||||
or read_attribute(device, 'product')
|
||||
or clean_name(device.properties.get('ID_MODEL')))
|
||||
if not model:
|
||||
return 'USB device'
|
||||
vendor = (read_attribute(device, 'manufacturer')
|
||||
or clean_name(device.properties.get('ID_VENDOR_FROM_DATABASE'))
|
||||
or clean_name(device.properties.get('ID_VENDOR')))
|
||||
if vendor and vendor.casefold() not in model.casefold():
|
||||
return '{} {}'.format(vendor, model)[:160]
|
||||
return model
|
||||
|
||||
|
||||
class UsbDevices:
|
||||
def __init__(self, devices):
|
||||
# Seed silently so devices already present are named when unplugged.
|
||||
self.names = {device.device_path: device_name(device)
|
||||
for device in devices if self.is_usb_device(device)}
|
||||
self.removedPaths = collections.deque(maxlen=64)
|
||||
|
||||
@staticmethod
|
||||
def is_usb_device(device):
|
||||
return (device.subsystem == 'usb'
|
||||
and device.device_type == 'usb_device')
|
||||
|
||||
def handle_event(self, device):
|
||||
if not self.is_usb_device(device):
|
||||
return None
|
||||
action = device.action
|
||||
devicePath = device.device_path
|
||||
if action in ('add', 'change'):
|
||||
wasPresent = devicePath in self.names
|
||||
name = device_name(device)
|
||||
if name != 'USB device' or not wasPresent:
|
||||
self.names[devicePath] = name
|
||||
if action != 'add' or wasPresent:
|
||||
return None
|
||||
if devicePath in self.removedPaths:
|
||||
self.removedPaths.remove(devicePath)
|
||||
return action, '{} connected'.format(self.names[devicePath])
|
||||
if action == 'remove':
|
||||
if devicePath in self.removedPaths:
|
||||
return None
|
||||
name = self.names.pop(devicePath, None) or device_name(device)
|
||||
self.removedPaths.append(devicePath)
|
||||
return action, '{} disconnected'.format(name)
|
||||
return None
|
||||
|
||||
|
||||
class Announcer:
|
||||
def __init__(self, stopEvent):
|
||||
self.stopEvent = stopEvent
|
||||
self.playCommand = shutil.which('play')
|
||||
self.speechCommand = shutil.which('spd-say')
|
||||
self.events = queue.Queue(maxsize=32)
|
||||
|
||||
def enqueue(self, event):
|
||||
if event is None or self.stopEvent.is_set():
|
||||
return
|
||||
try:
|
||||
self.events.put_nowait(event)
|
||||
except queue.Full:
|
||||
# Prefer recent events during a burst, without blocking udev reads.
|
||||
try:
|
||||
self.events.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
self.events.put_nowait(event)
|
||||
|
||||
def run_command(self, command, timeout):
|
||||
if self.stopEvent.is_set():
|
||||
return
|
||||
try:
|
||||
with subprocess.Popen(command, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL) as process:
|
||||
deadline = time.monotonic() + timeout
|
||||
while process.poll() is None:
|
||||
if self.stopEvent.wait(0.05) or time.monotonic() >= deadline:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
return
|
||||
if process.returncode:
|
||||
print('USB notifications: {} exited with status {}'.format(
|
||||
command[0], process.returncode), file=sys.stderr)
|
||||
except OSError as error:
|
||||
print('USB notifications: {}'.format(error), file=sys.stderr)
|
||||
|
||||
def run(self):
|
||||
while not self.stopEvent.is_set():
|
||||
try:
|
||||
action, message = self.events.get(timeout=0.2)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if self.playCommand:
|
||||
command = [self.playCommand] + list(CONNECT_SOUND[1:])
|
||||
if action == 'remove':
|
||||
command.append('reverse')
|
||||
self.run_command(command, 5)
|
||||
if self.speechCommand:
|
||||
# Do not use -C: hotplug must not globally cancel other speech.
|
||||
self.run_command([self.speechCommand, '-P', 'important', '-w',
|
||||
'--', message], 15)
|
||||
|
||||
|
||||
def acquire_lock():
|
||||
displayName = os.environ.get('DISPLAY', '')
|
||||
if not displayName:
|
||||
raise RuntimeError('an i3 X11 session is required (DISPLAY is unset)')
|
||||
runtimeDir = Path(os.environ.get('XDG_RUNTIME_DIR')
|
||||
or Path.home() / '.cache' / 'i38')
|
||||
runtimeDir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
sessionId = hashlib.sha256(displayName.encode('utf-8')).hexdigest()[:16]
|
||||
lockPath = runtimeDir / ('i38-usb-{}.lock'.format(sessionId))
|
||||
descriptor = os.open(str(lockPath), os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW,
|
||||
0o600)
|
||||
lockFile = os.fdopen(descriptor, 'w')
|
||||
try:
|
||||
fcntl.flock(lockFile, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
lockFile.close()
|
||||
return None
|
||||
# Leave the inode in place so concurrent launches cannot lock a new file.
|
||||
return lockFile
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
import i3ipc
|
||||
import pyudev
|
||||
except ImportError as error:
|
||||
print('USB notifications require python-pyudev and python-i3ipc: {}'.format(
|
||||
error), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
lockFile = acquire_lock()
|
||||
if lockFile is None:
|
||||
return 0
|
||||
with lockFile:
|
||||
connection = i3ipc.Connection(auto_reconnect=True)
|
||||
stopEvent = threading.Event()
|
||||
announcer = Announcer(stopEvent)
|
||||
if not announcer.playCommand and not announcer.speechCommand:
|
||||
print('USB notifications require play or spd-say.', file=sys.stderr)
|
||||
return 1
|
||||
context = pyudev.Context()
|
||||
monitor = pyudev.Monitor.from_netlink(context)
|
||||
monitor.filter_by(subsystem='usb', device_type='usb_device')
|
||||
# Start buffering before enumeration to avoid losing a removal.
|
||||
monitor.start()
|
||||
devices = UsbDevices(context.list_devices(
|
||||
subsystem='usb', DEVTYPE='usb_device'))
|
||||
observer = pyudev.MonitorObserver(
|
||||
monitor, callback=lambda device: announcer.enqueue(
|
||||
devices.handle_event(device)))
|
||||
worker = threading.Thread(target=announcer.run, daemon=True)
|
||||
|
||||
def stop_listener(*unused):
|
||||
stopEvent.set()
|
||||
connection.main_quit()
|
||||
|
||||
# Reconnect through i3 restarts; exit with the actual desktop session.
|
||||
connection.on('shutdown::exit', stop_listener)
|
||||
signal.signal(signal.SIGTERM, stop_listener)
|
||||
signal.signal(signal.SIGINT, stop_listener)
|
||||
try:
|
||||
observer.start()
|
||||
worker.start()
|
||||
connection.main()
|
||||
finally:
|
||||
stopEvent.set()
|
||||
observer.stop()
|
||||
if worker.is_alive():
|
||||
worker.join(timeout=2)
|
||||
return 0
|
||||
except (OSError, RuntimeError) as error:
|
||||
print('USB notifications: {}'.format(error), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user