feat: add bottles-opener

This commit is contained in:
Felitendo committed 2026-09-21 12:16:46 +02:00
commit c831d860f3
27 files changed
+5710

No files matched your search

+372
View File
@@ -0,0 +1,372 @@
#!/usr/bin/env bash
#
# bottles-opener - double-click a file and it opens in its Windows program,
# inside its bottle
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
set -uo pipefail
BO_LIBDIR="${BO_LIBDIR:-@LIBDIR@}"
# What the launchers run; a desktop session's PATH may differ.
BO_SELF="$(readlink -f -- "${BASH_SOURCE[0]}")"
for _mod in common config scan known mime assoc apply open menu; do
# shellcheck source=/dev/null
if ! source "$BO_LIBDIR/$_mod.sh"; then
printf 'bottles-opener: cannot load %s/%s.sh\n' "$BO_LIBDIR" "$_mod" >&2
exit 14
fi
done
unset _mod
# As root this would set up root's file types, not the user's.
if [[ $EUID -eq 0 && -z ${BO_ALLOW_ROOT:-} ]]; then
printf 'bottles-opener: run this as your own user, not as root.\n' >&2
exit 2
fi
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
_bo_need_bottles() {
bo_installs_detect
(( ${#BO_INSTALLS[@]} )) && return 0
bo_bad "$(bo_msg "Bottles is not installed - neither the package nor the Flatpak was found.")"
return 1
}
_bo_report() {
if (( BO_N_ACTIVE == 0 )); then
bo_note "$(bo_msg "No file types are set up yet. Add one with \"bottles-opener add\" or under File types in the menu.")"
else
bo_ok "$(bo_msg "File opening is on for %s." "$(bo_assoc_active_exts 12)")"
fi
}
bo_do_enable() {
_bo_need_bottles || return 1
bo_config_set Enabled yes || {
bo_bad "$(bo_msg "Could not save the setting.")"
return 1
}
bo_config_load
bo_apply || return 1
_bo_report
if [[ $CFG_WATCH == yes ]]; then
if bo_watch_available; then
bo_watch_enable || bo_note "$(bo_msg "Could not start the watcher for newly installed programs.")"
else
bo_note "$(bo_msg "No systemd user session - newly installed programs will not be picked up on their own.")"
fi
fi
}
bo_do_disable() {
bo_config_set Enabled no || {
bo_bad "$(bo_msg "Could not save the setting.")"
return 1
}
bo_revert
bo_watch_disable
bo_ok "$(bo_msg "File opening is off and everything that was changed has been put back.")"
}
bo_do_apply() {
local rebuild=0 arg
for arg in "$@"; do
case "$arg" in
--rebuild) rebuild=1 ;;
--quiet) BO_QUIET=1 ;;
esac
done
bo_config_load
if [[ $CFG_ENABLED != yes ]]; then
# The watcher, while switched off.
[[ -n $BO_QUIET ]] && return 0
bo_note "$(bo_msg "File opening is off. Turn it on first.")"
return 1
fi
# The repair: whatever drifted is written again.
if (( rebuild )); then
bo_revert
fi
bo_apply || return 1
if (( rebuild || BO_CHANGES )); then
_bo_report
elif (( BO_N_ACTIVE )); then
bo_ok "$(bo_msg "Already up to date for %s." "$(bo_assoc_active_exts 12)")"
else
_bo_report
fi
if [[ $CFG_WATCH == yes ]] && bo_watch_available; then
if bo_watch_enabled; then
bo_watch_refresh
else
bo_watch_enable || true
fi
elif [[ $CFG_WATCH != yes ]] && bo_watch_enabled; then
bo_watch_disable
fi
}
bo_do_status() {
bo_config_load
bo_head " $BO_PRETTY"
bo_ui_status
printf '\n'
}
# bo_do_list
# Every file type and what opens it.
bo_do_list() {
local k i label state pad l_default l_openwith l_off l_missing
bo_config_load
bo_assoc_compute
if (( ${#BO_A_ORDER[@]} == 0 )); then
bo_note "$(bo_msg "No file types yet.")"
return 0
fi
bo_msg_into "double-click"; l_default="$BO_MSG_RESULT"
bo_msg_into "Open With"; l_openwith="$BO_MSG_RESULT"
bo_msg_into "off"; l_off="$BO_MSG_RESULT"
bo_msg_into "program not found"; l_missing="$BO_MSG_RESULT"
bo_head " $(bo_msg "File types")"
for k in "${BO_A_ORDER[@]}"; do
i="${BO_A_PIDX[k]}"
if (( i >= 0 )); then
bo_program_label "$i"
label="$BO_LABEL"
case "${BO_A_STATE[k]}" in
default) state="$l_default" ;;
openwith) state="$l_openwith" ;;
*) state="$l_off" ;;
esac
else
label="${BO_A_BOTTLE[k]}"
state="$l_missing"
fi
printf -v label '%-9s %s' ".${BO_A_EXT[k]}" "$label"
pad=$(( 46 - ${#label} ))
(( pad < 0 )) && pad=0
printf ' %s%*s %s\n' "$label" "$pad" '' "$state"
done
printf '\n'
}
# bo_do_programs
# Every program in every bottle, with the file types it opens.
bo_do_programs() {
local i pad label
bo_config_load
_bo_need_bottles || return 1
bo_assoc_compute
if (( ${#BO_P_ID[@]} == 0 )); then
bo_note "$(bo_msg "No programs found in any bottle.")"
return 0
fi
bo_head " $(bo_msg "Programs")"
for i in "${!BO_P_ID[@]}"; do
bo_program_label "$i"
label="$BO_LABEL"
pad=$(( 46 - ${#label} ))
(( pad < 0 )) && pad=0
printf ' %s%*s %s%s%s\n' "$label" "$pad" '' "$BO_C_DIM" "$(bo_program_exts "$i")" "$BO_C_RESET"
done
printf '\n'
}
# _bo_find_program <program> [bottle]
# By name or id, optionally within one bottle. Index in BO_FOUND.
_bo_find_program() {
local want="$1" bottle="${2:-}" i
local -a hits=()
for i in "${!BO_P_ID[@]}"; do
[[ -n $bottle && ${BO_P_BOTTLE[i]} != "$bottle" ]] && continue
if [[ ${BO_P_ID[i]} == "$want" || ${BO_P_NAME[i],,} == "${want,,}" ]]; then
hits+=("$i")
fi
done
if (( ${#hits[@]} == 1 )); then
BO_FOUND="${hits[0]}"
return 0
fi
if (( ${#hits[@]} == 0 )); then
bo_bad "$(bo_msg "No program called \"%s\" was found. \"bottles-opener programs\" lists them all." "$want")"
else
bo_bad "$(bo_msg "More than one program is called \"%s\" - name the bottle as well." "$want")"
fi
return 1
}
# bo_do_add <extensions> <program> [bottle] [--open-with|--default]
# Extensions separated by commas: flp,fst.
bo_do_add() {
local state='' arg ext i
local -a pos=()
for arg in "$@"; do
case "$arg" in
--open-with) state=openwith ;;
--default) state=default ;;
*) pos+=("$arg") ;;
esac
done
if (( ${#pos[@]} < 2 )); then
bo_bad "$(bo_msg "Usage: bottles-opener add <extension> <program> [bottle] [--open-with|--default]")"
return 1
fi
if ! _bo_parse_exts "${pos[0]}" || (( ${#BO_EXTS[@]} == 0 )); then
bo_bad "$(bo_msg "\"%s\" is not a file extension." "${BO_EXT:-${pos[0]}}")"
return 1
fi
bo_config_load
_bo_need_bottles || return 1
bo_scan_once
_bo_find_program "${pos[1]}" "${pos[2]:-}" || return 1
# Saved: bo_assoc_compute looks programs up through BO_FOUND as well.
i=$BO_FOUND
bo_assoc_compute
for ext in "${BO_EXTS[@]}"; do
bo_assoc_add "$ext" "$i" "$state" || {
bo_bad "$(bo_msg "Could not save the setting.")"
return 1
}
done
if [[ $CFG_ENABLED == yes ]]; then
bo_do_apply
else
bo_ok "$(bo_msg "Saved. Turn file opening on with \"bottles-opener enable\".")"
fi
}
# bo_do_remove <extensions> [program] [bottle]
# With every program that opens them, or only the one named.
bo_do_remove() {
local k n=0 i ext
if (( $# < 1 )) || ! _bo_parse_exts "$1" || (( ${#BO_EXTS[@]} == 0 )); then
bo_bad "$(bo_msg "Usage: bottles-opener remove <extension> [program] [bottle]")"
return 1
fi
bo_config_load
bo_scan_once
local only=-1
if (( $# >= 2 )); then
_bo_find_program "$2" "${3:-}" || return 1
only=$BO_FOUND
fi
bo_assoc_compute
for ext in "${BO_EXTS[@]}"; do
for k in "${!BO_A_EXT[@]}"; do
[[ ${BO_A_EXT[k]} == "$ext" && ${BO_A_STATE[k]} != off ]] || continue
i="${BO_A_PIDX[k]}"
(( only < 0 || only == i )) || continue
bo_assoc_remove "$k" && n=$(( n + 1 ))
done
done
if (( n == 0 )); then
bo_note "$(bo_msg "Nothing opens .%s." "${BO_EXTS[*]}")"
return 1
fi
if [[ $CFG_ENABLED == yes ]]; then
bo_do_apply
else
bo_ok "$(bo_msg "Saved.")"
fi
}
bo_do_help() {
cat <<- EOF
$BO_PRETTY $BO_VERSION
$(bo_msg "Usage: bottles-opener [command]")
$(bo_msg "Commands:")
enable $(bo_msg "Turn file opening on")
disable $(bo_msg "Turn it off and put everything back")
apply $(bo_msg "Pick up programs installed since the last run")
apply --rebuild $(bo_msg "Take everything back and write it again")
status $(bo_msg "Show what is set up")
list $(bo_msg "List every file type and what opens it")
programs $(bo_msg "List every program and the file types it opens")
add <ext> <program> [bottle]
$(bo_msg "Open a file type with a program")
remove <ext> [program] $(bo_msg "Stop opening a file type")
-h, --help $(bo_msg "Show this help")
-V, --version $(bo_msg "Show the version")
$(bo_msg "Without a command an interactive menu is shown.")
EOF
}
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
main() {
local cmd="${1:-}"
[[ $# -gt 0 ]] && shift
case "$cmd" in
enable) bo_do_enable "$@" ;;
disable) bo_do_disable "$@" ;;
apply|refresh) bo_do_apply "$@" ;;
status) bo_do_status "$@" ;;
list) bo_do_list "$@" ;;
programs) bo_do_programs "$@" ;;
add) bo_do_add "$@" ;;
remove) bo_do_remove "$@" ;;
# What the launchers run. Not in the help: nobody types it.
open) bo_do_open "$@" ;;
-h|--help|help) bo_do_help ;;
-V|--version) printf '%s %s\n' "$BO_NAME" "$BO_VERSION" ;;
'') bo_ui_menu ;;
*)
bo_bad "$(bo_msg "Unknown command: %s" "$cmd")"
printf '\n'
bo_do_help
exit 1
;;
esac
}
main "$@"
+484
View File
@@ -0,0 +1,484 @@
# shellcheck shell=bash
#
# Writing it all down, and taking it back.
#
# bo_apply converges: works out what should exist, writes what differs, takes
# back what the ledger has and is no longer wanted. Idempotent, so the watcher
# can call it any time. Undoing is converging on nothing.
BO_CHANGES=0
# Stable, unique per program, nothing to quote.
bo_launcher_id() {
printf '%s\t%s' "$1" "$2" | sha1sum | cut -c1-12
}
# _bo_entry_escape <string>
# A desktop entry value. The scan already removed newlines.
_bo_entry_escape() {
printf '%s' "${1//\\/\\\\}"
}
# _bo_exec_arg <string>
# One Exec argument, escaped for both layers: string value and Exec quoting.
_bo_exec_arg() {
local s="$1"
s="${s//%/%%}"
if [[ $s =~ ^[A-Za-z0-9_./+%-]+$ ]]; then
printf '%s' "$s"
return
fi
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//\`/\\\`}"
s="${s//\$/\\\$}"
# and the string layer on top
s="${s//\\/\\\\}"
printf '"%s"' "$s"
}
# bo_launcher_entry <program index> <MIME types; separated> <launcher id> <display name>
bo_launcher_entry() {
local i="$1" mimes="$2" id="$3" name="$4" icon exe wmclass comment
icon="${BO_P_ICON[i]}"
if [[ $icon == /* && ! -f $icon ]] || [[ -z $icon ]]; then
icon="com.usebottles.bottles"
fi
exe="${BO_P_PATH[i]//\\//}"
exe="${exe##*/}"
wmclass="${exe,,}"
comment="$(bo_msg "Opens files in %s from the bottle \"%s\"" "${BO_P_NAME[i]}" "${BO_P_BOTTLE[i]}")"
printf '[Desktop Entry]\n'
printf 'Type=Application\n'
printf 'Name=%s\n' "$(_bo_entry_escape "$name")"
printf 'Comment=%s\n' "$(_bo_entry_escape "$comment")"
printf 'Icon=%s\n' "$(_bo_entry_escape "$icon")"
printf 'Exec=%s open %s %%f\n' "$(_bo_exec_arg "$BO_SELF")" "$id"
printf 'Terminal=false\n'
# Hidden: Bottles has the menu entry. Still opens its types and shows under
# "Open With", as the spec intends.
printf 'NoDisplay=true\n'
printf 'MimeType=%s\n' "$mimes"
[[ -n $wmclass ]] && printf 'StartupWMClass=%s\n' "$(_bo_entry_escape "$wmclass")"
printf 'X-Bottles-Opener-Id=%s\n' "$id"
}
# _bo_owned <path>
# Ours to overwrite: in the ledger, or carrying our mark (a lost ledger).
# Icons have no mark.
_bo_owned() {
local path="$1"
[[ -e $path ]] || return 0
bo_ledger_has file "$path" && return 0
case "$path" in
*.desktop) grep -q '^X-Bottles-Opener-Id=' "$path" 2>/dev/null ;;
*.xml) grep -q 'Written by bottles-opener' "$path" 2>/dev/null ;;
*) return 1 ;;
esac
}
# _bo_file_put <path> <content>
# Writes and records a file of ours. Never overwrites somebody else's.
_bo_file_put() {
local path="$1" content="$2" rc
if ! _bo_owned "$path"; then
bo_bad "$(bo_msg "Not overwriting %s - it was not written by this program." "$path")"
return 1
fi
bo_write_if_changed "$path" "$content"
rc=$?
(( rc == 2 )) && return 1
(( rc == 0 )) && _bo_mark_dirty "$path"
bo_ledger_add file "$path"
return 0
}
# _bo_file_copy <path> <source>
_bo_file_copy() {
local path="$1" src="$2"
_bo_owned "$path" || return 1
if ! cmp -s -- "$src" "$path" 2>/dev/null; then
mkdir -p "$(dirname "$path")" 2>/dev/null || return 1
cp -f -- "$src" "$path" 2>/dev/null || return 1
_bo_mark_dirty "$path"
fi
bo_ledger_add file "$path"
}
# What changed, so only those caches are rebuilt. BO_CHANGES: any of it.
BO_DIRTY_MIME=0
BO_DIRTY_APPS=0
BO_DIRTY_ICONS=0
_bo_mark_dirty() {
BO_CHANGES=1
case "$1" in
"$BO_MIMEPKG") BO_DIRTY_MIME=1 ;;
"$BO_APPDIR"/*) BO_DIRTY_APPS=1 ;;
"$BO_ICONDIR"/*) BO_DIRTY_ICONS=1 ;;
esac
}
# bo_icons_changed
# KIconLoader's iconChanged signal.
bo_icons_changed() {
if bo_have dbus-send; then
dbus-send --session --type=signal /KIconLoader \
org.kde.KIconLoader.iconChanged int32:0 > /dev/null 2>&1 || true
elif bo_have gdbus; then
gdbus emit --session --object-path /KIconLoader \
--signal org.kde.KIconLoader.iconChanged 0 > /dev/null 2>&1 || true
fi
}
# bo_refresh
# Rebuilds the desktop's caches. Every tool is optional.
bo_refresh() {
if (( BO_DIRTY_MIME )) && bo_have update-mime-database; then
mkdir -p "$BO_MIMEDIR/packages" 2>/dev/null
update-mime-database "$BO_MIMEDIR" > /dev/null 2>&1 || true
fi
if (( BO_DIRTY_APPS )) && bo_have update-desktop-database; then
update-desktop-database "$BO_APPDIR" > /dev/null 2>&1 || true
fi
if (( BO_DIRTY_ICONS )) && [[ -d $BO_ICONDIR ]] && bo_have gtk-update-icon-cache; then
# The user's hicolor has no index.theme of its own, hence -t.
gtk-update-icon-cache -q -t -f "$BO_ICONDIR" > /dev/null 2>&1 || true
fi
# Running KDE apps remember icons they did not find; this makes them look
# again, as System Settings does after an icon theme change.
if (( BO_DIRTY_ICONS )); then
bo_icons_changed
fi
# KDE's cache, rebuilt now rather than eventually.
if (( BO_DIRTY_MIME || BO_DIRTY_APPS )); then
local k
for k in kbuildsycoca6 kbuildsycoca5; do
if bo_have "$k"; then
"$k" > /dev/null 2>&1 || true
break
fi
done
fi
BO_DIRTY_MIME=0; BO_DIRTY_APPS=0; BO_DIRTY_ICONS=0
}
# bo_apply
bo_apply() {
local k i ext mime id path name src
local -a want_files=() launcher_lines=() command_lines=()
declare -A prog_mimes=() lid=() defaults=() default_prog=() names=() dup=()
declare -A icon_src=() icon_idx=() icon_prog=() icon_rank=()
declare -A BO_PKG_DESC=() BO_PKG_MAGIC=() BO_PKG_GICON=() BO_PKG_GLOBS=() BO_PKG_ICON=()
BO_PKG_ORDER=()
BO_CHANGES=0
bo_config_load
bo_assoc_compute
# Unreadable looks like empty, and converging on empty removes everything.
if [[ -n $BO_SCAN_ERROR ]]; then
bo_bad "$(bo_msg "Bottles could not be read - is python3 with PyYAML installed? Nothing was changed.")"
return 1
fi
for k in "${BO_A_ORDER[@]}"; do
i="${BO_A_PIDX[k]}"
[[ ${BO_A_STATE[k]} != off && $i -ge 0 ]] || continue
ext="${BO_A_EXT[k]}"
bo_mime_for "$ext"
mime="$BO_MIME"
[[ -n ${lid[$i]:-} ]] || lid[$i]="$(bo_launcher_id "${BO_P_INSTALL[i]}" "${BO_P_ID[i]}")"
[[ ";${prog_mimes[$i]:-}" == *";$mime;"* ]] || prog_mimes[$i]+="$mime;"
if [[ ${BO_A_STATE[k]} == default && -z ${default_prog[$mime]:-} ]]; then
default_prog[$mime]="$i"
fi
# How Windows starts the program for this extension.
[[ -n ${BO_A_ARGS[k]} ]] \
&& command_lines+=("${lid[$i]}"$'\t'"$ext"$'\t'"${BO_A_ARGS[k]}")
if (( BO_MIME_OWN )); then
if [[ -z ${BO_PKG_DESC[$mime]+set} ]]; then
bo_known_type "$ext"
BO_PKG_ORDER+=("$mime")
BO_PKG_DESC[$mime]="${BO_A_DESC[k]:-$BO_KT_DESC}"
BO_PKG_MAGIC[$mime]="$BO_KT_MAGIC"
BO_PKG_GICON[$mime]="$BO_KT_ICON"
BO_PKG_GLOBS[$mime]=''
fi
[[ " ${BO_PKG_GLOBS[$mime]} " == *" $ext "* ]] \
|| BO_PKG_GLOBS[$mime]+="${BO_PKG_GLOBS[$mime]:+ }$ext"
fi
# As in Windows: the icon of the default program. A system type keeps
# its own until then; one of ours takes the first program's.
if [[ $CFG_ICONS == yes ]] && { (( BO_MIME_OWN )) || [[ ${BO_A_STATE[k]} == default ]]; }; then
local rank=1
[[ ${BO_A_STATE[k]} == default ]] && rank=2
[[ -n ${BO_A_ICON[k]} ]] && rank=$(( rank + 2 ))
if (( rank > ${icon_rank[$mime]:-0} )); then
icon_rank[$mime]=$rank
icon_prog[$mime]=$i
if [[ -n ${BO_A_ICON[k]} ]]; then
icon_src[$mime]="${BO_A_ICON[k]}"; icon_idx[$mime]="${BO_A_IDX[k]}"
else
icon_src[$mime]="${BO_P_ICON[i]}"; icon_idx[$mime]=0
fi
fi
fi
done
# The same name twice in "Open With" gets the bottle added.
for i in "${!prog_mimes[@]}"; do
name="${BO_P_NAME[i]}"
[[ -n ${names[$name]+set} ]] && dup[$name]=1
names[$name]=1
done
# Icons. A system type goes into the package just for its icon.
for mime in "${!icon_src[@]}"; do
i="${icon_prog[$mime]}"
src="${icon_src[$mime]}"
# No Windows icon to be had: the program's own, as Bottles saved it.
if ! bo_icon_png "${BO_P_INSTALL[i]}" "$src" "${icon_idx[$mime]}"; then
[[ $src != "${BO_P_ICON[i]}" ]] || continue
bo_icon_png "${BO_P_INSTALL[i]}" "${BO_P_ICON[i]}" 0 || continue
fi
bo_icon_target "$mime" "$BO_ICON_PNG" || continue
_bo_file_copy "$BO_ICON_TARGET" "$BO_ICON_PNG" || continue
want_files+=("$BO_ICON_TARGET")
BO_PKG_ICON[$mime]="$BO_ICON_NAME"
[[ -n ${BO_PKG_DESC[$mime]+set} ]] || BO_PKG_ORDER+=("$mime")
done
if (( ${#BO_PKG_ORDER[@]} )); then
_bo_file_put "$BO_MIMEPKG" "$(bo_mime_package)" && want_files+=("$BO_MIMEPKG")
fi
# The launchers.
for i in "${!prog_mimes[@]}"; do
id="${lid[$i]}"
path="$BO_APPDIR/bottles-opener-$id.desktop"
name="${BO_P_NAME[i]}"
[[ -n ${dup[$name]+set} ]] && name="$name (${BO_P_BOTTLE[i]})"
_bo_file_put "$path" "$(bo_launcher_entry "$i" "${prog_mimes[$i]}" "$id" "$name")" \
|| continue
want_files+=("$path")
launcher_lines+=("$(printf '%s\t%s\t%s\t%s\t%s\t%s' "$id" "${BO_P_INSTALL[i]}" \
"${BO_P_ID[i]}" "${BO_P_BOTTLE[i]}" "${BO_P_BDIR[i]}" "${BO_P_NAME[i]}")")
for mime in "${!default_prog[@]}"; do
[[ ${default_prog[$mime]} == "$i" ]] && defaults[$mime]="bottles-opener-$id.desktop"
done
done
# What `open` reads.
mkdir -p "$BO_STATEDIR" 2>/dev/null
if (( ${#launcher_lines[@]} )); then
printf '%s\n' "${launcher_lines[@]}" > "$BO_LAUNCHERS"
else
rm -f -- "$BO_LAUNCHERS"
fi
if (( ${#command_lines[@]} )); then
printf '%s\n' "${command_lines[@]}" > "$BO_COMMANDS"
else
rm -f -- "$BO_COMMANDS"
fi
_bo_prune_files "${want_files[@]}"
# Defaults, once the launchers they name exist.
for mime in "${!defaults[@]}"; do
_bo_default_set "$mime" "${defaults[$mime]};"
done
local m
while IFS= read -r m; do
[[ -n $m && -z ${defaults[$m]:-} ]] && _bo_default_restore "$m"
done < <(bo_ledger_list default)
bo_refresh
bo_assoc_counts
bo_state_write last_apply "$(date +%s)"
return 0
}
# _bo_prune_files <wanted path>...
_bo_prune_files() {
local f w keep
while IFS= read -r f; do
[[ -n $f ]] || continue
keep=0
for w in "$@"; do
[[ $w == "$f" ]] && { keep=1; break; }
done
(( keep )) && continue
# Only the file; the directory may have been there before.
if [[ -e $f ]]; then
rm -f -- "$f" && _bo_mark_dirty "$f"
fi
bo_ledger_forget file "$f"
done < <(bo_ledger_list file)
}
# _bo_default_set <MIME type> <value>
_bo_default_set() {
local mime="$1" value="$2" current
current="$(bo_mimeapps_get "$mime")"
[[ $current == "$value" ]] && { bo_ledger_add default "$mime" "-"; return 0; }
# Set here before, changed since in the desktop's settings: that choice
# stands. See bo_default_claim.
if [[ -n $current ]] && ! bo_is_ours "$current" && bo_ledger_has default "$mime"; then
return 0
fi
# What was there before, unless one of ours.
if bo_is_ours "$current" || [[ -z $current ]]; then
bo_ledger_add default "$mime" "-"
else
bo_ledger_add default "$mime" "$current"
fi
bo_mimeapps_set "$mime" "$value" && { BO_CHANGES=1; BO_DIRTY_APPS=1; }
}
# bo_default_claim <extension>
# Made the default by hand: take the type back even from a later choice, by
# forgetting the record. The next apply records the current one instead.
bo_default_claim() {
local current
bo_mime_for "$1"
current="$(bo_mimeapps_get "$BO_MIME")"
# Still ours: keep the record of what came first.
bo_is_ours "$current" && return 0
bo_ledger_forget default "$BO_MIME"
}
# _bo_default_restore <MIME type>
# Puts back what was there, if the default is still ours.
_bo_default_restore() {
local mime="$1" prev current
prev="$(bo_ledger_detail default "$mime")"
current="$(bo_mimeapps_get "$mime")"
if bo_is_ours "$current"; then
[[ $prev == "-" ]] && prev=''
bo_mimeapps_set "$mime" "$prev" && { BO_CHANGES=1; BO_DIRTY_APPS=1; }
fi
bo_ledger_forget default "$mime"
}
# bo_revert
# Takes back every recorded change.
bo_revert() {
local m
BO_CHANGES=0
_bo_prune_files
while IFS= read -r m; do
[[ -n $m ]] && _bo_default_restore "$m"
done < <(bo_ledger_list default)
rm -f -- "$BO_LAUNCHERS" "$BO_COMMANDS"
[[ -s $BO_LEDGER ]] || rm -f -- "$BO_LEDGER"
bo_refresh
return 0
}
# ---------------------------------------------------------------------------
# The watcher
# ---------------------------------------------------------------------------
# A systemd path unit on the bottles directories and every bottle.yml. What the
# shipped unit cannot know goes into a drop-in.
BO_UNIT_PATH="bottles-opener.path"
BO_UNIT_SERVICE="bottles-opener.service"
BO_UNIT_DROPIN="${BO_XDG_CONFIG}/systemd/user/${BO_UNIT_PATH}.d"
bo_watch_available() {
[[ -z ${BO_NO_WATCH:-} ]] || return 1
bo_have systemctl && [[ -n ${XDG_RUNTIME_DIR:-} || -S "/run/user/$(id -u)/bus" ]]
}
bo_watch_enabled() {
bo_watch_available || return 1
systemctl --user is-enabled --quiet "$BO_UNIT_PATH" 2>/dev/null
}
# _bo_watch_dropin_write
# 0 when it changed and systemd needs a reload, 1 when not.
_bo_watch_dropin_write() {
local content="[Path]"$'\n' p
bo_scan_once
for p in "${BO_ROOTS[@]}"; do
# The unit watches the default places already.
[[ $p == "$HOME/.local/share/bottles/bottles" ]] && continue
[[ $p == "$HOME/.var/app/$BO_FLATPAK_ID/data/bottles/bottles" ]] && continue
# % is a unit file specifier.
content+="PathModified=${p//%/%%}"$'\n'
done
for p in "${BO_CONFS[@]}"; do
content+="PathChanged=${p//%/%%}"$'\n'
done
bo_write_if_changed "$BO_UNIT_DROPIN/bottles.conf" "$content"
}
_bo_watch_dropin_remove() {
[[ -e "$BO_UNIT_DROPIN/bottles.conf" ]] || return 1
rm -f -- "$BO_UNIT_DROPIN/bottles.conf"
rmdir "$BO_UNIT_DROPIN" 2>/dev/null || true
return 0
}
bo_watch_enable() {
bo_watch_available || return 1
_bo_watch_dropin_write
systemctl --user daemon-reload > /dev/null 2>&1 || true
systemctl --user enable --now "$BO_UNIT_PATH" > /dev/null 2>&1 || return 1
return 0
}
# bo_watch_refresh
# Keeps a running watcher in step with the bottles that exist now.
bo_watch_refresh() {
bo_watch_available || return 0
_bo_watch_dropin_write || return 0
systemctl --user daemon-reload > /dev/null 2>&1 || true
systemctl --user restart "$BO_UNIT_PATH" > /dev/null 2>&1 || true
return 0
}
bo_watch_disable() {
bo_watch_available || return 0
systemctl --user disable --now "$BO_UNIT_PATH" > /dev/null 2>&1 || true
_bo_watch_dropin_remove && systemctl --user daemon-reload > /dev/null 2>&1
return 0
}
+281
View File
@@ -0,0 +1,281 @@
# shellcheck shell=bash
#
# Which program opens which file type.
#
# Three sources, later ones winning: what each program registered in its
# bottle's registry, the table in known.sh, and what was set by hand in the
# types file.
#
# States:
# default opens on a double click
# openwith only offered under "Open With"
# off left alone
#
# Used by bo_apply to write everything and by the screens to show it, so what
# they show is what happens.
BO_A_EXT=() # extension, without the dot
BO_A_STATE=() # default | openwith | off
BO_A_PIDX=() # index into BO_P_*, -1 for a program that is gone
BO_A_SRC=() # detected | added
BO_A_INSTALL=()
BO_A_PID=()
BO_A_BOTTLE=()
BO_A_ARGS=() # Windows argument template, empty for plain "%1"
BO_A_ICON=() # file holding the type's Windows icon, empty for none
BO_A_IDX=() # its DefaultIcon index
BO_A_DESC=() # the type's name in Windows, empty for none
BO_A_ORDER=() # indices into BO_A_*, sorted by extension then program
# _bo_assoc_push <ext> <state> <program index> <source> <install> <id> <bottle>
_bo_assoc_push() {
local r="${BO_R_AT[$1$'\x1f'$5$'\x1f'$6]:-}"
BO_A_EXT+=("$1"); BO_A_STATE+=("$2"); BO_A_PIDX+=("$3"); BO_A_SRC+=("$4")
BO_A_INSTALL+=("$5"); BO_A_PID+=("$6"); BO_A_BOTTLE+=("$7")
if [[ -n $r ]]; then
BO_A_ARGS+=("${BO_R_ARGS[r]}"); BO_A_ICON+=("${BO_R_ICON[r]}")
BO_A_IDX+=("${BO_R_IDX[r]}"); BO_A_DESC+=("${BO_R_DESC[r]}")
else
BO_A_ARGS+=("${BO_KNOWN_ARGS[$1]:-}"); BO_A_ICON+=(''); BO_A_IDX+=(0); BO_A_DESC+=('')
fi
}
bo_assoc_compute() {
local i j k r ext key
declare -A at=() has_default=()
BO_A_EXT=(); BO_A_STATE=(); BO_A_PIDX=(); BO_A_SRC=()
BO_A_INSTALL=(); BO_A_PID=(); BO_A_BOTTLE=()
BO_A_ARGS=(); BO_A_ICON=(); BO_A_IDX=(); BO_A_DESC=(); BO_A_ORDER=()
bo_scan_once
# Detected: registered in the bottle, or known from the table. "auto" is
# settled below, once every default set by hand is known.
if [[ $CFG_DETECT == yes ]]; then
for r in "${!BO_R_EXT[@]}"; do
bo_program_find "${BO_R_INSTALL[r]}" "${BO_R_PID[r]}" || continue
i=$BO_FOUND
key="${BO_R_EXT[r]}"$'\x1f'"${BO_R_INSTALL[r]}"$'\x1f'"${BO_R_PID[r]}"
[[ -n ${at[$key]+set} ]] && continue
at[$key]=${#BO_A_EXT[@]}
BO_KNOWN_ARGS=()
_bo_assoc_push "${BO_R_EXT[r]}" auto "$i" detected \
"${BO_R_INSTALL[r]}" "${BO_R_PID[r]}" "${BO_P_BOTTLE[i]}"
done
for i in "${!BO_P_ID[@]}"; do
bo_known_exts "${BO_P_PATH[i]}" || continue
for ext in $BO_KNOWN; do
key="$ext"$'\x1f'"${BO_P_INSTALL[i]}"$'\x1f'"${BO_P_ID[i]}"
[[ -n ${at[$key]+set} ]] && continue
at[$key]=${#BO_A_EXT[@]}
_bo_assoc_push "$ext" auto "$i" detected \
"${BO_P_INSTALL[i]}" "${BO_P_ID[i]}" "${BO_P_BOTTLE[i]}"
done
done
fi
# Set by hand.
for j in "${!BO_T_EXT[@]}"; do
key="${BO_T_EXT[j]}"$'\x1f'"${BO_T_INSTALL[j]}"$'\x1f'"${BO_T_PID[j]}"
if [[ -n ${at[$key]+set} ]]; then
BO_A_STATE[at[$key]]="${BO_T_STATE[j]}"
continue
fi
i=-1
bo_program_find "${BO_T_INSTALL[j]}" "${BO_T_PID[j]}" && i=$BO_FOUND
# Switched off for a program nothing detects any more: nothing to say.
[[ $i -lt 0 && ${BO_T_STATE[j]} == off ]] && continue
at[$key]=${#BO_A_EXT[@]}
BO_KNOWN_ARGS=()
(( i >= 0 )) && bo_known_exts "${BO_P_PATH[i]}"
_bo_assoc_push "${BO_T_EXT[j]}" "${BO_T_STATE[j]}" "$i" added \
"${BO_T_INSTALL[j]}" "${BO_T_PID[j]}" "${BO_T_BOTTLE[j]}"
done
# One default per extension: the first one set by hand.
for k in "${!BO_A_EXT[@]}"; do
[[ ${BO_A_STATE[k]} == default && ${BO_A_PIDX[k]} -ge 0 ]] || continue
ext="${BO_A_EXT[k]}"
if [[ -n ${has_default[$ext]+set} ]]; then
BO_A_STATE[k]=openwith
else
has_default[$ext]=1
fi
done
# A detected type is the default when this program teaches the system the
# type - nothing else could open it. A type the system knows already
# belongs to some application somebody chose, and only gets "Open With".
for k in "${!BO_A_EXT[@]}"; do
[[ ${BO_A_STATE[k]} == auto ]] || continue
ext="${BO_A_EXT[k]}"
bo_mime_for "$ext"
if (( BO_MIME_OWN )) && [[ -z ${has_default[$ext]+set} ]]; then
BO_A_STATE[k]=default
has_default[$ext]=1
else
BO_A_STATE[k]=openwith
fi
done
# Sorted for display.
if (( ${#BO_A_EXT[@]} )); then
local label
while IFS=$'\x1f' read -r _ _ k; do
BO_A_ORDER+=("$k")
done < <(
for k in "${!BO_A_EXT[@]}"; do
if (( BO_A_PIDX[k] >= 0 )); then
label="${BO_P_NAME[BO_A_PIDX[k]]}"
else
label="~"
fi
printf '%s\x1f%s\x1f%s\n' "${BO_A_EXT[k]}" "${label,,}" "$k"
done | LC_ALL=C sort -t $'\x1f' -k1,1 -k2,2
)
fi
}
# bo_assoc_counts
# BO_N_ACTIVE: types that are on, with their program present.
BO_N_ACTIVE=0
bo_assoc_counts() {
local k
BO_N_ACTIVE=0
for k in "${!BO_A_EXT[@]}"; do
[[ ${BO_A_STATE[k]} != off && ${BO_A_PIDX[k]} -ge 0 ]] \
&& BO_N_ACTIVE=$(( BO_N_ACTIVE + 1 ))
done
}
# bo_assoc_active_exts [max]
# The extensions that are on, sorted, deduplicated. With a maximum, the rest
# is counted: ".flp .fst .doc +12".
bo_assoc_active_exts() {
local max="${1:-0}" k n=0 out=''
declare -A seen=()
for k in "${BO_A_ORDER[@]}"; do
[[ ${BO_A_STATE[k]} != off && ${BO_A_PIDX[k]} -ge 0 ]] || continue
[[ -n ${seen[${BO_A_EXT[k]}]+set} ]] && continue
seen[${BO_A_EXT[k]}]=1
n=$(( n + 1 ))
(( max > 0 && n > max )) && continue
out+="${out:+ }.${BO_A_EXT[k]}"
done
(( max > 0 && n > max )) && out+=" +$(( n - max ))"
printf '%s\n' "$out"
}
# bo_program_exts <program index> [max]
# The extensions that are on for one program, like bo_assoc_active_exts.
bo_program_exts() {
local i="$1" max="${2:-0}" k n=0 out=''
for k in "${BO_A_ORDER[@]}"; do
[[ ${BO_A_PIDX[k]} == "$i" && ${BO_A_STATE[k]} != off ]] || continue
n=$(( n + 1 ))
(( max > 0 && n > max )) && continue
out+="${out:+ }.${BO_A_EXT[k]}"
done
(( max > 0 && n > max )) && out+=" +$(( n - max ))"
printf '%s\n' "$out"
}
# _bo_assoc_demote <extension> <install> <program id>
# Every other program that is the default for the extension becomes an
# "Open With" entry.
_bo_assoc_demote() {
local ext="$1" install="$2" pid="$3" o
for o in "${!BO_A_EXT[@]}"; do
[[ ${BO_A_EXT[o]} == "$ext" && ${BO_A_STATE[o]} == default ]] || continue
[[ ${BO_A_INSTALL[o]} == "$install" && ${BO_A_PID[o]} == "$pid" ]] && continue
bo_types_set "$ext" openwith "${BO_A_INSTALL[o]}" \
"${BO_A_PID[o]}" "${BO_A_BOTTLE[o]}" || return 1
done
}
# bo_assoc_set <index into BO_A_*> <state>
bo_assoc_set() {
local k="$1" state="$2"
if [[ $state == default ]]; then
_bo_assoc_demote "${BO_A_EXT[k]}" "${BO_A_INSTALL[k]}" "${BO_A_PID[k]}" || return 1
fi
bo_types_set "${BO_A_EXT[k]}" "$state" "${BO_A_INSTALL[k]}" \
"${BO_A_PID[k]}" "${BO_A_BOTTLE[k]}" || return 1
[[ $state == default ]] && bo_default_claim "${BO_A_EXT[k]}"
return 0
}
# bo_assoc_remove <index into BO_A_*>
# Added by hand: gone. Detected: switched off, or detection brings it back.
bo_assoc_remove() {
local k="$1" how=remove
[[ ${BO_A_SRC[k]} == detected ]] && how=off
bo_types_set "${BO_A_EXT[k]}" "$how" "${BO_A_INSTALL[k]}" \
"${BO_A_PID[k]}" "${BO_A_BOTTLE[k]}"
}
# bo_assoc_add <extension> <index into BO_P_*> [state]
# Without a state: the default for a type only this program can open, unless
# another program is already; "Open With" for one the system knows.
bo_assoc_add() {
local ext="$1" i="$2" state="${3:-}" k
if [[ -z $state ]]; then
bo_mime_for "$ext"
state=default
(( BO_MIME_OWN )) || state=openwith
for k in "${!BO_A_EXT[@]}"; do
if [[ ${BO_A_EXT[k]} == "$ext" && ${BO_A_STATE[k]} == default \
&& ${BO_A_PIDX[k]} -ge 0 && ${BO_A_PIDX[k]} -ne $i ]]; then
state=openwith
break
fi
done
fi
if [[ $state == default ]]; then
_bo_assoc_demote "$ext" "${BO_P_INSTALL[i]}" "${BO_P_ID[i]}" || return 1
fi
bo_types_set "$ext" "$state" "${BO_P_INSTALL[i]}" "${BO_P_ID[i]}" "${BO_P_BOTTLE[i]}" \
|| return 1
[[ $state == default ]] && bo_default_claim "$ext"
return 0
}
# bo_program_set_exts <program index> <extension>...
# Makes exactly these the types the program opens: new ones are added, ones
# left out are removed. Returns 1 when something could not be saved.
bo_program_set_exts() {
local i="$1" k ext
shift
local want=" $* "
for k in "${BO_A_ORDER[@]}"; do
[[ ${BO_A_PIDX[k]} == "$i" && ${BO_A_STATE[k]} != off ]] || continue
[[ $want == *" ${BO_A_EXT[k]} "* ]] && continue
bo_assoc_remove "$k" || return 1
done
for ext in "$@"; do
for k in "${BO_A_ORDER[@]}"; do
[[ ${BO_A_PIDX[k]} == "$i" && ${BO_A_EXT[k]} == "$ext" && ${BO_A_STATE[k]} != off ]] \
&& continue 2
done
bo_assoc_add "$ext" "$i" || return 1
done
}
+264
View File
@@ -0,0 +1,264 @@
# shellcheck shell=bash
#
# Paths, translations, output helpers and the change ledger.
BO_VERSION="@VERSION@"
BO_NAME="bottles-opener"
BO_PRETTY="Bottles Opener"
BO_LIBDIR="${BO_LIBDIR:-@LIBDIR@}"
BO_LOCALEDIR="${BO_LOCALEDIR:-@LOCALEDIR@}"
BO_XDG_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}"
BO_XDG_DATA="${XDG_DATA_HOME:-$HOME/.local/share}"
BO_XDG_STATE="${XDG_STATE_HOME:-$HOME/.local/state}"
BO_CONFDIR="${BO_CONFDIR:-${BO_XDG_CONFIG}/${BO_NAME}}"
BO_CONFIG="${BO_CONFIG:-${BO_CONFDIR}/config}"
BO_TYPES="${BO_TYPES:-${BO_CONFDIR}/types}"
BO_STATEDIR="${BO_STATEDIR:-${BO_XDG_STATE}/${BO_NAME}}"
BO_CACHEDIR="${BO_CACHEDIR:-${XDG_CACHE_HOME:-$HOME/.cache}/${BO_NAME}}"
# Launchers, each named bottles-opener-<id>.desktop.
BO_APPDIR="${BO_XDG_DATA}/applications"
# The user's MIME database; update-mime-database compiles packages/.
BO_MIMEDIR="${BO_XDG_DATA}/mime"
BO_MIMEPKG="${BO_MIMEDIR}/packages/${BO_NAME}.xml"
# hicolor: the theme every other theme falls back to.
BO_ICONDIR="${BO_XDG_DATA}/icons/hicolor"
# Default applications. Every desktop reads this one.
BO_MIMEAPPS="${BO_XDG_CONFIG}/mimeapps.list"
# Every change, so `disable` undoes exactly that. See bo_ledger_add.
BO_LEDGER="${BO_STATEDIR}/ledger"
# Which launcher starts what, read by `open`. Exec lines carry only the id:
# no bottle name to quote.
BO_LAUNCHERS="${BO_STATEDIR}/launchers"
# The Windows arguments per launcher and extension, as the registry has them.
BO_COMMANDS="${BO_STATEDIR}/commands"
BO_FLATPAK_ID="com.usebottles.bottles"
# ---------------------------------------------------------------------------
# Translations
# ---------------------------------------------------------------------------
export TEXTDOMAIN="bottles-opener"
export TEXTDOMAINDIR="${BO_LOCALEDIR}"
bo_ui_locale() {
local l="${BO_UI_LOCALE:-}"
if [[ -z $l ]]; then
l="${LC_ALL:-}"
[[ -z $l ]] && l="${LC_MESSAGES:-}"
[[ -z $l ]] && l="${LANG:-}"
fi
# systemd's file, and Debian's.
if [[ -z $l ]]; then
local f
for f in /etc/locale.conf /etc/default/locale; do
[[ -r $f ]] || continue
l="$(sed -n 's/^LANG=//p' "$f" | tr -d '"' | head -n1)"
[[ -n $l ]] && break
done
fi
printf '%s\n' "${l:-C}"
}
# Resolved once, and lookups memoized: each is a fork, and screens redraw per
# keypress.
BO_LOCALE=''
declare -A BO_MSG_CACHE=()
BO_MSG_RESULT=''
# bo_msg_into <msgid>
# Lookup into BO_MSG_RESULT, without a fork.
bo_msg_into() {
local msgid="$1"
[[ -n $BO_LOCALE ]] || BO_LOCALE="$(bo_ui_locale)"
if [[ -n ${BO_MSG_CACHE[$msgid]+set} ]]; then
BO_MSG_RESULT="${BO_MSG_CACHE[$msgid]}"
return 0
fi
BO_MSG_RESULT="$(LC_ALL="$BO_LOCALE" LANGUAGE="${BO_LOCALE%%.*}" gettext -- "$msgid" 2>/dev/null)"
[[ -n $BO_MSG_RESULT ]] || BO_MSG_RESULT="$msgid"
BO_MSG_CACHE[$msgid]="$BO_MSG_RESULT"
return 0
}
# bo_msg <msgid> [printf args...]
bo_msg() {
local msgid="$1"
shift
bo_msg_into "$msgid"
# Without arguments it is text, not a format: a % would break printf.
if (( $# == 0 )); then
printf '%s' "$BO_MSG_RESULT"
return
fi
# shellcheck disable=SC2059 # the format string is the translated message
printf -- "$BO_MSG_RESULT" "$@"
}
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
# Decided once: inside $(...) stdout is always a pipe.
BO_INTERACTIVE=''
[[ -t 1 ]] && BO_INTERACTIVE=1
if [[ -n $BO_INTERACTIVE && -z ${NO_COLOR:-} ]]; then
BO_C_RESET=$'\033[0m'
BO_C_BOLD=$'\033[1m'
BO_C_DIM=$'\033[2m'
BO_C_BLUE=$'\033[38;2;23;147;209m'
BO_C_GREEN=$'\033[32m'
BO_C_YELLOW=$'\033[33m'
BO_C_RED=$'\033[31m'
else
BO_C_RESET='' BO_C_BOLD='' BO_C_DIM='' BO_C_BLUE=''
BO_C_GREEN='' BO_C_YELLOW='' BO_C_RED=''
fi
# Set by bo_bad and bo_note: the menu waits for a key before redrawing.
BO_UI_NEEDS_ACK=''
# --quiet, for the systemd unit. Errors still go to stderr.
BO_QUIET=''
bo_say() { [[ -n $BO_QUIET ]] || printf '%s\n' "$*"; }
bo_head() { printf '\n%s%s%s\n\n' "$BO_C_BOLD$BO_C_BLUE" "$*" "$BO_C_RESET"; }
bo_ok() { [[ -n $BO_QUIET ]] || printf '%s✔%s %s\n' "$BO_C_GREEN" "$BO_C_RESET" "$*"; }
bo_bad() { BO_UI_NEEDS_ACK=1; printf '%s✘%s %s\n' "$BO_C_RED" "$BO_C_RESET" "$*" >&2; }
bo_note() { BO_UI_NEEDS_ACK=1; [[ -n $BO_QUIET ]] || printf '%s•%s %s\n' "$BO_C_DIM" "$BO_C_RESET" "$*"; }
bo_have() { command -v "$1" > /dev/null 2>&1; }
# "x minutes ago" for a unix timestamp, "never" for none.
bo_time_ago() {
local ts="$1" now delta
[[ $ts =~ ^[0-9]+$ ]] || { bo_msg "never"; printf '\n'; return; }
now="$(date +%s)"
delta=$(( now - ts ))
(( delta < 0 )) && delta=0
if (( delta < 60 )); then bo_msg "just now"
elif (( delta < 120 )); then bo_msg "1 minute ago"
elif (( delta < 3600 )); then bo_msg "%d minutes ago" "$(( delta / 60 ))"
elif (( delta < 7200 )); then bo_msg "1 hour ago"
elif (( delta < 86400 )); then bo_msg "%d hours ago" "$(( delta / 3600 ))"
elif (( delta < 172800 )); then bo_msg "1 day ago"
else bo_msg "%d days ago" "$(( delta / 86400 ))"
fi
printf '\n'
}
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
bo_state_read() {
local key="$1" default="${2:-}"
if [[ -r "$BO_STATEDIR/$key" ]]; then
cat "$BO_STATEDIR/$key"
else
printf '%s\n' "$default"
fi
}
bo_state_write() {
local key="$1"
shift
mkdir -p "$BO_STATEDIR" 2>/dev/null || return 1
printf '%s\n' "$*" > "$BO_STATEDIR/$key"
}
# ---------------------------------------------------------------------------
# The change ledger
# ---------------------------------------------------------------------------
# Every change, so undoing never touches what this did not write. A set, tab
# separated:
# file <path> - deleted on undo
# default <MIME type> <previous> put back on undo, "-" for none
bo_ledger_add() {
local kind="$1" key="$2" detail="${3:--}"
mkdir -p "$BO_STATEDIR" 2>/dev/null || return 1
# Keep the value from before the first time.
if [[ $kind == default ]] && bo_ledger_has default "$key"; then
return 0
fi
bo_ledger_forget "$kind" "$key"
printf '%s\t%s\t%s\n' "$kind" "$key" "$detail" >> "$BO_LEDGER"
}
# bo_ledger_has <kind> <key>
bo_ledger_has() {
[[ -f $BO_LEDGER ]] || return 1
awk -F'\t' -v k="$1" -v p="$2" '$1 == k && $2 == p { found = 1 } END { exit !found }' \
"$BO_LEDGER"
}
# bo_ledger_detail <kind> <key>
bo_ledger_detail() {
[[ -f $BO_LEDGER ]] || return 1
awk -F'\t' -v k="$1" -v p="$2" '$1 == k && $2 == p { d = $3; f = 1 } END { if (!f) exit 1; print d }' \
"$BO_LEDGER"
}
bo_ledger_forget() {
local kind="$1" key="$2" tmp
[[ -f $BO_LEDGER ]] || return 0
tmp="$(mktemp "${BO_LEDGER}.XXXXXX")" || return 1
awk -F'\t' -v k="$kind" -v p="$key" '!($1 == k && $2 == p)' "$BO_LEDGER" > "$tmp" 2>/dev/null \
&& mv -f "$tmp" "$BO_LEDGER" || rm -f "$tmp"
return 0
}
# bo_ledger_list <kind>
# Every key of that kind, one per line.
bo_ledger_list() {
[[ -f $BO_LEDGER ]] || return 0
awk -F'\t' -v k="$1" '$1 == k { print $2 }' "$BO_LEDGER"
}
# bo_write_if_changed <path> <content>
# 0 written, 1 already so, 2 failed. An unchanged file keeps its timestamp, so
# desktops do not rebuild their caches for nothing.
bo_write_if_changed() {
local path="$1" content="$2" current=''
# $(...) drops the final newline.
[[ $content == *$'\n' ]] || content+=$'\n'
# Byte for byte: $(< file) would drop trailing newlines.
[[ -r $path ]] && IFS= read -rd '' current < "$path"
[[ $current == "$content" ]] && return 1
mkdir -p "$(dirname "$path")" 2>/dev/null || return 2
printf '%s' "$content" > "$path" || return 2
return 0
}
+165
View File
@@ -0,0 +1,165 @@
# shellcheck shell=bash
#
# Reading and writing ~/.config/bottles-opener/config and .../types.
# Written by the interface, never by hand. Parsed, not sourced.
BO_CONFIG_CACHE=''
BO_CONFIG_CACHED=0
_bo_config_slurp() {
(( BO_CONFIG_CACHED )) && return 0
BO_CONFIG_CACHE=''
[[ -r $BO_CONFIG ]] && BO_CONFIG_CACHE="$(< "$BO_CONFIG")"
BO_CONFIG_CACHED=1
return 0
}
# _bo_config_lookup <Key> [default]
# Into BO_CONFIG_VALUE, without a fork: the settings screen reads every key per
# frame.
BO_CONFIG_VALUE=''
_bo_config_lookup() {
local key="$1" default="${2:-}" val='' line
BO_CONFIG_VALUE="$default"
[[ -r $BO_CONFIG ]] || return 0
_bo_config_slurp
while IFS= read -r line; do
[[ $line == *"$key"* ]] || continue
[[ $line =~ ^[[:space:]]*"$key"[[:space:]]*=(.*)$ ]] || continue
val="${BASH_REMATCH[1]}"
done <<< "$BO_CONFIG_CACHE"
val="${val%%#*}"
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
val="${val%\"}"
val="${val#\"}"
[[ -n $val ]] && BO_CONFIG_VALUE="$val"
return 0
}
# bo_config_get <Key> [default]
bo_config_get() {
_bo_config_lookup "$@"
printf '%s\n' "$BO_CONFIG_VALUE"
}
_bo_is_true() {
case "${1,,}" in
yes|y|true|1|on|enabled) return 0 ;;
*) return 1 ;;
esac
}
# bo_config_bool <Key> <default: yes|no>
bo_config_bool() {
_bo_config_lookup "$1" "$2"
_bo_is_true "$BO_CONFIG_VALUE"
}
# bo_config_set <Key> <Value>
bo_config_set() {
local key="$1" value="$2" tmp
if [[ ! -e $BO_CONFIG ]]; then
mkdir -p "$(dirname "$BO_CONFIG")" || return 1
{
printf '# %s\n' "$BO_PRETTY"
printf '#\n'
printf '# Written by `%s`. Nothing here needs editing by hand -\n' "$BO_NAME"
printf '# every option is reachable from `%s` itself.\n' "$BO_NAME"
} > "$BO_CONFIG" || return 1
fi
[[ -w $BO_CONFIG ]] || return 1
tmp="$(mktemp "${BO_CONFIG}.XXXXXX")" || return 1
chmod --reference="$BO_CONFIG" "$tmp" 2>/dev/null || chmod 0644 "$tmp"
if grep -qE "^[[:space:]]*#?[[:space:]]*${key}[[:space:]]*=" "$BO_CONFIG"; then
awk -v key="$key" -v value="$value" '
!done && $0 ~ "^[[:space:]]*#?[[:space:]]*" key "[[:space:]]*=" {
print key "=" value; done = 1; next
}
# drop any further occurrences so the file cannot grow duplicates
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" { next }
{ print }
' "$BO_CONFIG" > "$tmp" || { rm -f "$tmp"; return 1; }
else
cat "$BO_CONFIG" > "$tmp" || { rm -f "$tmp"; return 1; }
printf '%s=%s\n' "$key" "$value" >> "$tmp"
fi
mv -f "$tmp" "$BO_CONFIG"
BO_CONFIG_CACHED=0
}
# ---------------------------------------------------------------------------
# Resolved settings
# ---------------------------------------------------------------------------
bo_config_load() {
CFG_ENABLED=no; bo_config_bool Enabled no && CFG_ENABLED=yes
CFG_DETECT=no; bo_config_bool DetectKnown yes && CFG_DETECT=yes
CFG_ICONS=no; bo_config_bool FileIcons yes && CFG_ICONS=yes
CFG_WATCH=no; bo_config_bool WatchNewPrograms yes && CFG_WATCH=yes
bo_types_load
}
# ---------------------------------------------------------------------------
# The types file
# ---------------------------------------------------------------------------
# Types changed by hand. Detected ones have no line: they follow detection.
# Tab separated, no field empty:
# <extension> <default|openwith|off> <install> <program id> <bottle name>
# The program is found by install and id; the bottle name is for reading.
BO_T_EXT=()
BO_T_STATE=()
BO_T_INSTALL=()
BO_T_PID=()
BO_T_BOTTLE=()
bo_types_load() {
local ext state install pid bottle
BO_T_EXT=(); BO_T_STATE=(); BO_T_INSTALL=(); BO_T_PID=(); BO_T_BOTTLE=()
[[ -r $BO_TYPES ]] || return 0
while IFS=$'\t' read -r ext state install pid bottle; do
[[ -z $ext || $ext == '#'* || -z $pid ]] && continue
case "$state" in default|openwith|off) ;; *) continue ;; esac
BO_T_EXT+=("$ext"); BO_T_STATE+=("$state"); BO_T_INSTALL+=("$install")
BO_T_PID+=("$pid"); BO_T_BOTTLE+=("$bottle")
done < "$BO_TYPES"
}
# bo_types_set <ext> <state|remove> <install> <program id> <bottle name>
# Replaces the line for that extension and program, or takes it out.
bo_types_set() {
local ext="$1" state="$2" install="$3" pid="$4" bottle="$5" tmp
mkdir -p "$(dirname "$BO_TYPES")" || return 1
if [[ ! -e $BO_TYPES ]]; then
{
printf '# %s - file types changed by hand\n' "$BO_PRETTY"
printf '#\n'
printf '# Written by `%s`. Nothing here needs editing by hand.\n' "$BO_NAME"
} > "$BO_TYPES" || return 1
fi
tmp="$(mktemp "${BO_TYPES}.XXXXXX")" || return 1
awk -F'\t' -v e="$ext" -v i="$install" -v p="$pid" \
'!($1 == e && $3 == i && $4 == p)' "$BO_TYPES" > "$tmp" \
|| { rm -f "$tmp"; return 1; }
if [[ $state != remove ]]; then
printf '%s\t%s\t%s\t%s\t%s\n' "$ext" "$state" "$install" "$pid" "$bottle" >> "$tmp"
fi
mv -f "$tmp" "$BO_TYPES"
bo_types_load
}
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
#
# Writes one icon of a Windows program as PNG to stdout - the largest image of
# it, the way Explorer would pick it for a big view.
#
# Usage: icon.py <file> [index]
# <file> an .exe or .dll, an .ico, or a .png
# [index] Windows' DefaultIcon index: n >= 0 is the n-th icon, -n the icon
# with resource id n
#
# Needs icoextract for executables, which Bottles depends on. Exit codes:
# 3 icoextract missing, 4 no such icon.
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
import struct
import sys
import zlib
PNG_SIG = b"\x89PNG\r\n\x1a\n"
def png_encode(width, height, rgba):
def chunk(kind, data):
return (
struct.pack(">I", len(data)) + kind + data
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
)
stride = width * 4
raw = b"".join(b"\0" + rgba[y * stride:(y + 1) * stride] for y in range(height))
return (
PNG_SIG
+ chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b"")
)
def dib_to_png(data, width, height):
"""An icon's bitmap image: BITMAPINFOHEADER, palette, colour, AND mask."""
(hdr, w, h2, _planes, bpp, compression) = struct.unpack_from("<IiiHHI", data, 0)
if compression != 0 or bpp not in (1, 4, 8, 24, 32):
raise ValueError("unsupported bitmap")
width = width or w
height = height or abs(h2) // 2
colours = struct.unpack_from("<I", data, 32)[0] or (1 << bpp if bpp <= 8 else 0)
palette_at = hdr
pixels_at = palette_at + colours * 4
stride = ((width * bpp + 31) // 32) * 4
mask_at = pixels_at + stride * height
mask_stride = ((width + 31) // 32) * 4
has_mask = len(data) >= mask_at + mask_stride * height
out = bytearray(width * height * 4)
any_alpha = False
for y in range(height):
row = pixels_at + (height - 1 - y) * stride
for x in range(width):
if bpp == 32:
b, g, r, a = data[row + x * 4:row + x * 4 + 4]
any_alpha = any_alpha or a != 0
elif bpp == 24:
b, g, r = data[row + x * 3:row + x * 3 + 3]
a = 255
else:
bit = x * bpp
byte = data[row + bit // 8]
index = (byte >> (8 - bpp - bit % 8)) & ((1 << bpp) - 1)
b, g, r = data[palette_at + index * 4:palette_at + index * 4 + 3]
a = 255
o = (y * width + x) * 4
out[o:o + 4] = bytes((r, g, b, a))
# Without alpha of its own the AND mask says what is transparent.
if has_mask and not (bpp == 32 and any_alpha):
for y in range(height):
row = mask_at + (height - 1 - y) * mask_stride
for x in range(width):
if data[row + x // 8] & (0x80 >> (x % 8)):
out[(y * width + x) * 4 + 3] = 0
return png_encode(width, height, bytes(out))
def ico_to_png(ico):
reserved, kind, count = struct.unpack_from("<HHH", ico, 0)
if reserved != 0 or kind != 1 or count == 0:
raise ValueError("not an icon")
entries = []
for i in range(count):
w, h, _c, _r, _p, bpp, size, offset = struct.unpack_from("<BBBBHHII", ico, 6 + 16 * i)
data = ico[offset:offset + size]
if data[:8] == PNG_SIG:
w = struct.unpack_from(">I", data, 16)[0]
h = struct.unpack_from(">I", data, 20)[0]
bpp = bpp or 32
entries.append((w or 256, h or 256, bpp or 32, data))
# Largest first, then the most colours.
for w, h, _bpp, data in sorted(entries, key=lambda e: (e[0], e[2]), reverse=True):
try:
return data if data[:8] == PNG_SIG else dib_to_png(data, w, h)
except (ValueError, struct.error, IndexError):
continue
raise ValueError("no usable image")
def main():
if len(sys.argv) < 2:
return 2
path = sys.argv[1]
index = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2] else 0
try:
with open(path, "rb") as f:
head = f.read(8)
except OSError:
return 4
try:
if head == PNG_SIG:
with open(path, "rb") as f:
png = f.read()
elif head[:4] == b"\0\0\1\0":
with open(path, "rb") as f:
png = ico_to_png(f.read())
else:
try:
import icoextract
except ImportError:
return 3
extractor = icoextract.IconExtractor(path)
if index < 0:
ico = extractor.get_icon(resource_id=-index)
else:
ico = extractor.get_icon(num=index)
png = ico_to_png(ico.getvalue())
except Exception: # anything a broken or unusual file can throw
return 4
sys.stdout.buffer.write(png)
return 0
if __name__ == "__main__":
sys.exit(main())
+220
View File
@@ -0,0 +1,220 @@
# shellcheck shell=bash
#
# Programs whose file types are known without asking.
#
# The bottle's registry is asked first (scan.py): whatever an installer
# registered there is what Windows would open. This table is for programs
# that registered nothing - portable installs, copied folders.
# Format: <pattern>|<ext>[=<switch>] ...
#
# The pattern matches the program's Windows path, lowercased, with / for \.
# A switch goes in front of the file, as in `OUTLOOK.EXE /f "file.msg"`.
BO_KNOWN_PROGRAMS=(
# Microsoft Office
"*/winword.exe|doc docx docm dot dotx dotm rtf odt"
"*/excel.exe|xls xlsx xlsm xlsb xlt xltx xltm xlam xla csv ods"
"*/powerpnt.exe|ppt pptx pptm pot potx potm pps=/s ppsx=/s ppsm=/s ppa ppam odp"
"*/outlook.exe|msg=/f oft=/t eml=/eml vcf=/v ics=/ical vcs=/vcal"
"*/msaccess.exe|accdb accde accdr accdt mdb mde"
"*/mspub.exe|pub"
"*/visio.exe|vsd vsdx vsdm vss vssx vssm vst vstx vstm"
"*/winproj.exe|mpp mpt"
"*/onenote.exe|one onepkg onetoc2"
# Music
"*/fl64.exe|flp fsc fst"
"*/fl.exe|flp fsc fst"
"*/ableton live *.exe|als alc adg"
"*/cubase*.exe|cpr"
"*/nuendo*.exe|npr"
"*/studio one.exe|song"
"*/reaper.exe|rpp"
"*/reason.exe|reason"
"*/adobe audition.exe|sesx"
"*/guitarpro*.exe|gp gpx gp5 gp4 gp3"
"*/sibelius.exe|sib"
"*/finale.exe|musx mus"
"*/foobar2000.exe|mp3 flac ogg opus m4a wav ape wv"
# Images and design
"*/photoshop.exe|psd psb"
"*/photoshopelementseditor.exe|psd pse"
"*/illustrator.exe|ai"
"*/indesign.exe|indd indt idml"
"*/lightroom.exe|lrcat"
"*/animate.exe|fla"
"*/affinity*/photo*.exe|afphoto"
"*/affinity*/designer*.exe|afdesign"
"*/affinity*/publisher*.exe|afpub"
"*/clipstudiopaint.exe|clip"
"*/coreldrw.exe|cdr"
"*/paintdotnet.exe|pdn"
"*/sai.exe|sai"
"*/sai2.exe|sai2"
# 3D and CAD
"*/sketchup.exe|skp"
"*/acad.exe|dwg dxf dwt"
"*/acadlt.exe|dwg dxf dwt"
"*/rhino.exe|3dm"
"*/3dsmax.exe|max"
"*/sldworks.exe|sldprt sldasm slddrw"
"*/x2.exe|pcbdoc schdoc prjpcb"
# Video
"*/afterfx.exe|aep"
"*/adobe premiere pro.exe|prproj"
"*/vegas*.exe|veg"
# Everything else
"*/acrobat.exe|pdf"
"*/acrord32.exe|pdf"
"*/notepad++.exe|txt log"
"*/7zfm.exe|7z zip rar"
"*/winrar.exe|rar zip 7z"
)
# Types for extensions the system does not know.
#
# Format: <ext>|<MIME type>|<description>|<magic>|<generic icon>|<flags>
#
# <magic>: string:<text> or big32:<hex>, matched at offset 0.
# <flags>: "own" defines the type even where the system has the extension
# already, as something else - .mpp is Musepack audio there, not a Project
# file. The magic tells the two apart.
BO_KNOWN_TYPES=(
# Office
"ppa|application/x-ms-powerpoint-addin|PowerPoint add-in||x-office-presentation|"
"msg|application/vnd.ms-outlook|Outlook message||x-office-document|"
"oft|application/x-ms-outlook-template|Outlook template||x-office-document|"
"accdb|application/x-ms-access-database|Access database||x-office-spreadsheet|"
"accde|application/x-ms-access-database|Access database||x-office-spreadsheet|"
"accdr|application/x-ms-access-database|Access database||x-office-spreadsheet|"
"accdt|application/x-ms-access-template|Access template||x-office-spreadsheet|"
"mde|application/x-ms-access-database|Access database||x-office-spreadsheet|"
"mpp|application/vnd.ms-project|Project file|big32:0xd0cf11e0|x-office-spreadsheet|own"
"mpt|application/x-ms-project-template|Project template||x-office-spreadsheet|"
"one|application/onenote|OneNote section||x-office-document|"
"onepkg|application/x-onenote-package|OneNote package||x-office-document|"
"onetoc2|application/x-onenote-toc|OneNote table of contents||x-office-document|"
# Music
"flp|application/x-flstudio-project|FL Studio project|string:FLhd|audio-x-generic|"
"fsc|application/x-flstudio-score|FL Studio score||audio-x-generic|"
"fst|application/x-flstudio-preset|FL Studio preset|string:FLhd|audio-x-generic|own"
"als|application/x-ableton-live-set|Ableton Live set||audio-x-generic|"
"alc|application/x-ableton-live-clip|Ableton Live clip||audio-x-generic|"
"adg|application/x-ableton-device-group|Ableton device group||audio-x-generic|"
"cpr|application/x-cubase-project|Cubase project||audio-x-generic|"
"npr|application/x-nuendo-project|Nuendo project||audio-x-generic|"
"song|application/x-studio-one-song|Studio One song||audio-x-generic|"
"rpp|application/x-reaper-project|REAPER project|string:<REAPER_PROJECT|audio-x-generic|"
"reason|application/x-reason-song|Reason song||audio-x-generic|"
"sesx|application/x-audition-session|Audition session||audio-x-generic|"
"gp|application/x-guitar-pro|Guitar Pro tablature|big32:0x504b0304|audio-x-generic|own"
"gpx|application/x-guitar-pro|Guitar Pro tablature|string:BCFS|audio-x-generic|own"
"gp5|application/x-guitar-pro|Guitar Pro tablature||audio-x-generic|"
"gp4|application/x-guitar-pro|Guitar Pro tablature||audio-x-generic|"
"gp3|application/x-guitar-pro|Guitar Pro tablature||audio-x-generic|"
"sib|application/x-sibelius-score|Sibelius score||audio-x-generic|"
"musx|application/x-finale-score|Finale score||audio-x-generic|"
"mus|application/x-finale-score|Finale score||audio-x-generic|"
# Images and design
"psb|application/x-photoshop-large-document|Photoshop large document||image-x-generic|"
"pse|application/x-photoshop-elements|Photoshop Elements document||image-x-generic|"
"indd|application/x-indesign-document|InDesign document||x-office-document|"
"indt|application/x-indesign-template|InDesign template||x-office-document|"
"idml|application/vnd.adobe.indesign-idml-package|InDesign markup||x-office-document|"
"lrcat|application/x-lightroom-catalog|Lightroom catalog||image-x-generic|"
"fla|application/x-adobe-animate|Animate document||video-x-generic|"
"afphoto|application/x-affinity-photo|Affinity Photo document||image-x-generic|"
"afdesign|application/x-affinity-designer|Affinity Designer document||image-x-generic|"
"afpub|application/x-affinity-publisher|Affinity Publisher document||x-office-document|"
"clip|application/x-clip-studio-paint|Clip Studio Paint file||image-x-generic|"
"pdn|application/x-paintdotnet|Paint.NET image||image-x-generic|"
"sai|application/x-paint-tool-sai|PaintTool SAI document||image-x-generic|"
"sai2|application/x-paint-tool-sai2|PaintTool SAI document||image-x-generic|"
# 3D and CAD
"skp|application/vnd.sketchup.skp|SketchUp model||x-office-drawing|"
"dwt|application/x-autocad-template|AutoCAD template||x-office-drawing|"
"3dm|application/x-rhino-model|Rhino model||x-office-drawing|"
"max|application/x-3ds-max-scene|3ds Max scene||x-office-drawing|"
"sldprt|application/x-solidworks-part|SolidWorks part||x-office-drawing|"
"sldasm|application/x-solidworks-assembly|SolidWorks assembly||x-office-drawing|"
"slddrw|application/x-solidworks-drawing|SolidWorks drawing||x-office-drawing|"
"pcbdoc|application/x-altium-pcb|Altium PCB||x-office-drawing|"
"schdoc|application/x-altium-schematic|Altium schematic||x-office-drawing|"
"prjpcb|application/x-altium-project|Altium project||x-office-drawing|"
# Video
"aep|application/x-after-effects-project|After Effects project||video-x-generic|"
"prproj|application/x-premiere-project|Premiere Pro project||video-x-generic|"
"veg|application/x-vegas-project|VEGAS project||video-x-generic|"
)
# bo_known_exts <windows path>
# BO_KNOWN: the extensions, space separated. BO_KNOWN_ARGS[ext]: the Windows
# argument template for those that need a switch.
BO_KNOWN=''
declare -A BO_KNOWN_ARGS=()
bo_known_exts() {
local path="${1,,}" rule pattern entry ext
path="${path//\\//}"
BO_KNOWN=''
BO_KNOWN_ARGS=()
for rule in "${BO_KNOWN_PROGRAMS[@]}"; do
pattern="${rule%%|*}"
# shellcheck disable=SC2053 # the right-hand side is a pattern on purpose
[[ $path == $pattern ]] || continue
for entry in ${rule#*|}; do
ext="${entry%%=*}"
[[ " $BO_KNOWN " == *" $ext "* ]] && continue
BO_KNOWN="${BO_KNOWN:+$BO_KNOWN }$ext"
[[ $entry == *=* ]] && BO_KNOWN_ARGS[$ext]="${entry#*=} \"%1\""
done
done
[[ -n $BO_KNOWN ]]
}
# bo_known_type <extension>
# BO_KT_MIME, _DESC, _MAGIC, _ICON and _OWN for an extension. Anything not in
# the table gets a type named after the extension and this program, so it
# cannot collide with a real one. Returns 1 for those.
BO_KT_MIME=''
BO_KT_DESC=''
BO_KT_MAGIC=''
BO_KT_ICON=''
BO_KT_OWN=0
declare -A BO_KT_INDEX=()
bo_known_type() {
local ext="$1" entry e mime desc magic icon flags
if (( ${#BO_KT_INDEX[@]} == 0 )); then
for entry in "${BO_KNOWN_TYPES[@]}"; do
BO_KT_INDEX[${entry%%|*}]="$entry"
done
fi
if [[ -n ${BO_KT_INDEX[$ext]:-} ]]; then
IFS='|' read -r e mime desc magic icon flags <<< "${BO_KT_INDEX[$ext]}"
BO_KT_MIME="$mime"; BO_KT_DESC="$desc"
BO_KT_MAGIC="$magic"; BO_KT_ICON="$icon"
BO_KT_OWN=0
[[ $flags == *own* ]] && BO_KT_OWN=1
return 0
fi
BO_KT_MIME="application/x-bottles-opener-${ext}"
BO_KT_DESC="${ext^^} file"
BO_KT_MAGIC=''
BO_KT_ICON='application-x-generic'
BO_KT_OWN=0
return 1
}
+704
View File
@@ -0,0 +1,704 @@
# shellcheck shell=bash
#
# The interactive front end.
# Non-canonical mode for the whole interface: between two `read -sn1` bash
# goes back to canonical mode, where a backspace gets eaten.
BO_TERM_SAVED=''
bo_ui_term_raw() {
bo_have stty || return 0
[[ -t 0 ]] || return 0
[[ -n $BO_TERM_SAVED ]] && return 0
BO_TERM_SAVED="$(stty -g 2>/dev/null)" || { BO_TERM_SAVED=''; return 0; }
stty -icanon -echo min 1 time 0 2>/dev/null || true
}
bo_ui_term_restore() {
[[ -n $BO_TERM_SAVED ]] || return 0
stty "$BO_TERM_SAVED" 2>/dev/null || true
BO_TERM_SAVED=''
}
# Runs an action in normal line mode.
bo_ui_cooked() {
bo_ui_term_restore
"$@"
local rc=$?
bo_ui_term_raw
return $rc
}
# bo_read_key
# One keypress as a name. Escape sequences are read whole.
bo_read_key() {
local k rest tail
IFS= read -rsn1 k || return 1
case "$k" in
$'\e')
if IFS= read -rsn2 -t 0.05 rest; then
case "$rest" in
'[A') printf 'up\n' ;;
'[B') printf 'down\n' ;;
'[C') printf 'right\n' ;;
'[D') printf 'left\n' ;;
'[3')
IFS= read -rsn1 -t 0.05 tail || true
printf 'delete\n'
;;
*) printf 'escape\n' ;;
esac
else
printf 'escape\n'
fi
;;
''|$'\r') printf 'enter\n' ;;
$'\x7f'|$'\b') printf 'backspace\n' ;;
' ') printf 'space\n' ;;
*) printf '%s\n' "$k" ;;
esac
}
# bo_ui_read_line <initial>
# A line editor into BO_LINE_RESULT. `read -r` would break the following
# `read -sn1`.
BO_LINE_RESULT=''
bo_ui_read_line() {
local buf="${1:-}" key
BO_LINE_RESULT=''
printf '%s' "$buf"
while true; do
key="$(bo_read_key)" || { printf '\n'; return 1; }
case "$key" in
enter)
printf '\n'
BO_LINE_RESULT="$buf"
return 0
;;
escape)
printf '\n'
return 1
;;
backspace)
if [[ -n $buf ]]; then
buf="${buf%?}"
printf '\b \b'
fi
;;
space)
buf+=' '
printf ' '
;;
up|down|left|right|delete) ;;
*)
[[ ${#key} -eq 1 ]] || continue
buf+="$key"
printf '%s' "$key"
;;
esac
done
}
bo_pause() {
printf '\n %s' "$(bo_msg "Press any key to continue...")"
read -rsn1 _ || true
printf '\n'
}
# _bo_row <label> <value>
# Padded by characters: printf pads by bytes, which "ü" gets wrong.
_bo_row() {
local label="$1" value="$2" pad
pad=$(( 30 - ${#label} ))
(( pad < 0 )) && pad=0
printf ' %s%*s %s\n' "$label" "$pad" '' "$value"
}
_bo_onoff() {
if [[ $1 == yes ]]; then
printf '%s%s%s' "$BO_C_GREEN" "$(bo_msg "ON")" "$BO_C_RESET"
else
printf '%s%s%s' "$BO_C_DIM" "$(bo_msg "OFF")" "$BO_C_RESET"
fi
}
BO_CLEARSEQ=''
_bo_clearseq() {
[[ -n $BO_CLEARSEQ ]] && return 0
BO_CLEARSEQ="$(clear 2>/dev/null)" || BO_CLEARSEQ=$'\033[H\033[2J'
}
# _bo_term_rows [reserved]
# How many list rows fit, in BO_ROWS.
BO_ROWS=20
_bo_term_rows() {
local size
size="$(stty size 2>/dev/null)" || size='24 80'
BO_ROWS=$(( ${size%% *} - ${1:-8} ))
(( BO_ROWS < 5 )) && BO_ROWS=5
}
# _bo_scroll <cursor> <top>
# The first row to show so the cursor is in view, in BO_TOP.
BO_TOP=0
_bo_scroll() {
BO_TOP=$2
(( $1 < BO_TOP )) && BO_TOP=$1
(( $1 >= BO_TOP + BO_ROWS )) && BO_TOP=$(( $1 - BO_ROWS + 1 ))
(( BO_TOP < 0 )) && BO_TOP=0
}
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
# bo_ui_status
# For `status` and the menu header, from a fresh scan.
bo_ui_status() {
local last exts nb=0 np install where=''
bo_assoc_compute
bo_assoc_counts
last="$(bo_state_read last_apply '')"
_bo_row "$(bo_msg "File opening")" "$(_bo_onoff "$CFG_ENABLED")"
printf '\n'
for install in "${BO_INSTALLS[@]}"; do
case "$install" in
native) where+="${where:+, }$(bo_msg "package")" ;;
flatpak) where+="${where:+, }Flatpak" ;;
esac
done
if [[ -z $where ]]; then
_bo_row "Bottles" "${BO_C_YELLOW}$(bo_msg "not found")${BO_C_RESET}"
elif [[ -n $BO_SCAN_ERROR ]]; then
_bo_row "Bottles" \
"${BO_C_RED}$(bo_msg "could not be read - is python3 with PyYAML installed?")${BO_C_RESET}"
else
_bo_row "Bottles" "$where"
fi
nb=${#BO_B_NAME[@]}
np=${#BO_P_ID[@]}
if (( nb == 1 )); then
_bo_row "$(bo_msg "Programs found")" "$(bo_msg "%d in 1 bottle" "$np")"
else
_bo_row "$(bo_msg "Programs found")" "$(bo_msg "%d in %d bottles" "$np" "$nb")"
fi
exts="$(bo_assoc_active_exts 10)"
if (( BO_N_ACTIVE )); then
_bo_row "$(bo_msg "File types")" "$exts"
else
_bo_row "$(bo_msg "File types")" "${BO_C_DIM}$(bo_msg "none yet - add one under File types")${BO_C_RESET}"
fi
if bo_watch_available; then
if bo_watch_enabled; then
_bo_row "$(bo_msg "New programs")" "$(_bo_onoff yes)"
else
_bo_row "$(bo_msg "New programs")" "$(_bo_onoff no)"
fi
fi
_bo_row "$(bo_msg "Last applied")" "$(bo_time_ago "$last")"
if [[ $CFG_ENABLED == yes ]] && (( BO_N_ACTIVE )); then
printf '\n %s%s%s\n' "$BO_C_DIM" \
"$(bo_msg "Double-click one of these files and it opens in its program.")" \
"$BO_C_RESET"
fi
}
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
# Format: Key|default|label-msgid
BO_SETTINGS=(
"DetectKnown|yes|Find the file types of installed programs"
"FileIcons|yes|Give files the icon of their program"
"WatchNewPrograms|yes|Pick up newly installed programs"
)
# bo_ui_settings
# Returns 0 when something changed.
bo_ui_settings() {
local count=${#BO_SETTINGS[@]}
local -a names=() defaults=() labels=() values=()
local spec name default label i key frame row pad dirty=1 cursor=0 touched=0
local l_on l_off title hint shown
bo_msg_into "ON"; l_on="$BO_MSG_RESULT"
bo_msg_into "OFF"; l_off="$BO_MSG_RESULT"
for spec in "${BO_SETTINGS[@]}"; do
IFS='|' read -r name default label <<< "$spec"
names+=("$name"); defaults+=("$default")
bo_msg_into "$label"
labels+=("$BO_MSG_RESULT")
done
bo_msg_into "Settings"; title="$BO_MSG_RESULT"
bo_msg_into "Up/Down select - Space changes - q goes back"
hint="$BO_MSG_RESULT"
_bo_clearseq
while true; do
if (( dirty )); then
for i in "${!names[@]}"; do
_bo_config_lookup "${names[i]}" "${defaults[i]}"
values[i]="$BO_CONFIG_VALUE"
done
dirty=0
fi
frame="$BO_CLEARSEQ"$'\n'"${BO_C_BOLD}${BO_C_BLUE} ${title}${BO_C_RESET}"$'\n\n'
local marker selected="${BO_C_BLUE}▸${BO_C_RESET} "
for i in "${!names[@]}"; do
if _bo_is_true "${values[i]}"; then
shown="${BO_C_GREEN}${l_on}${BO_C_RESET}"
else
shown="${BO_C_DIM}${l_off}${BO_C_RESET}"
fi
pad=$(( 46 - ${#labels[i]} ))
(( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s' "$marker" "${labels[i]}" "$pad" '' "$shown"
frame+="$row"$'\n'
done
frame+=$'\n'" ${BO_C_DIM}${hint}${BO_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(bo_read_key)" || return "$(( ! touched ))"
case "$key" in
up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) cursor=$(( (cursor + 1) % count )) ;;
space|enter|right|l|left|h)
if _bo_is_true "${values[cursor]}"; then
bo_config_set "${names[cursor]}" no
else
bo_config_set "${names[cursor]}" yes
fi || { bo_bad "$(bo_msg "Could not save the setting.")"; bo_pause; }
touched=1
dirty=1
;;
q|Q|escape) return "$(( ! touched ))" ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# Picking a program
# ---------------------------------------------------------------------------
# bo_ui_pick_program <title>
# The chosen program's index in BO_PICKED; 1 for none.
BO_PICKED=-1
bo_ui_pick_program() {
local title="$1" count=${#BO_P_ID[@]} cursor=0 i key frame row hint
local -a labels=()
BO_PICKED=-1
if (( count == 0 )); then
printf '\n %s\n' "$(bo_msg "No programs found in any bottle.")"
bo_pause
return 1
fi
for i in "${!BO_P_ID[@]}"; do
bo_program_label "$i"
labels+=("$BO_LABEL")
done
bo_msg_into "Up/Down select - Enter picks - q cancels"
hint="$BO_MSG_RESULT"
_bo_clearseq
_bo_term_rows 7
local top=0
while true; do
_bo_scroll "$cursor" "$top"; top=$BO_TOP
frame="$BO_CLEARSEQ"$'\n'"${BO_C_BOLD}${BO_C_BLUE} ${title}${BO_C_RESET}"$'\n\n'
for i in "${!labels[@]}"; do
(( i < top || i >= top + BO_ROWS )) && continue
if (( i == cursor )); then
printf -v row ' %s %s' "${BO_C_BLUE}▸${BO_C_RESET}" "${labels[i]}"
else
printf -v row ' %s' "${labels[i]}"
fi
frame+="$row"$'\n'
done
frame+=$'\n'" ${BO_C_DIM}${hint}${BO_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(bo_read_key)" || return 1
case "$key" in
up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) cursor=$(( (cursor + 1) % count )) ;;
enter|space|right|l)
BO_PICKED=$cursor
return 0
;;
q|Q|escape|left|h) return 1 ;;
*) ;;
esac
done
}
# _bo_parse_exts <text>
# "flp, .FST *.fsc" -> BO_EXTS=(flp fst fsc). 1 with the bad one in BO_EXT.
BO_EXTS=()
_bo_parse_exts() {
local word
BO_EXTS=()
for word in ${1//,/ }; do
bo_ext_normalize "$word" || return 1
[[ " ${BO_EXTS[*]} " == *" $BO_EXT "* ]] || BO_EXTS+=("$BO_EXT")
done
}
# bo_ui_add_type
# Extensions, then the program. 0 when something was added.
bo_ui_add_type() {
local ext
printf '\n %s\n' "$(bo_msg "Which file extensions? For example: flp fst")"
printf ' > '
bo_ui_read_line '' || return 1
[[ -n ${BO_LINE_RESULT// /} ]] || return 1
if ! _bo_parse_exts "$BO_LINE_RESULT"; then
bo_bad "$(bo_msg "\"%s\" is not a file extension." "$BO_EXT")"
bo_pause
return 1
fi
bo_ui_pick_program "$(bo_msg "Which program opens %s?" ".${BO_EXTS[*]}")" || return 1
bo_assoc_compute
for ext in "${BO_EXTS[@]}"; do
bo_assoc_add "$ext" "$BO_PICKED" || {
bo_bad "$(bo_msg "Could not save the setting.")"
bo_pause
return 1
}
done
return 0
}
# ---------------------------------------------------------------------------
# Programs
# ---------------------------------------------------------------------------
# bo_ui_programs
# Programs and their types; Enter edits the list, custom extensions included.
# 0 when something changed.
bo_ui_programs() {
local key frame row pad i count cursor=0 top=0 dirty=1 touched=0 exts current
local title hint empty
local -a labels=() pexts=()
bo_msg_into "Programs"; title="$BO_MSG_RESULT"
bo_msg_into "Up/Down select - Enter edits its file types - q goes back"
hint="$BO_MSG_RESULT"
bo_msg_into "No programs found in any bottle."; empty="$BO_MSG_RESULT"
_bo_clearseq
printf '%s\n %s\n' "$BO_CLEARSEQ" "$(bo_msg "Looking at the bottles...")"
bo_config_load
bo_scan_once
count=${#BO_P_ID[@]}
for i in "${!BO_P_ID[@]}"; do
bo_program_label "$i"
labels+=("$BO_LABEL")
done
_bo_term_rows 7
while true; do
if (( dirty )); then
bo_config_load
bo_assoc_compute
pexts=()
for i in "${!labels[@]}"; do
pexts[i]="$(bo_program_exts "$i" 6)"
done
dirty=0
fi
frame="$BO_CLEARSEQ"$'\n'"${BO_C_BOLD}${BO_C_BLUE} ${title}${BO_C_RESET}"$'\n\n'
(( count == 0 )) && frame+=" ${BO_C_DIM}${empty}${BO_C_RESET}"$'\n'
_bo_scroll "$cursor" "$top"; top=$BO_TOP
local marker selected="${BO_C_BLUE}▸${BO_C_RESET} "
for i in "${!labels[@]}"; do
(( i < top || i >= top + BO_ROWS )) && continue
exts="${pexts[i]}"
[[ -n $exts ]] || exts="${BO_C_DIM}-${BO_C_RESET}"
pad=$(( 40 - ${#labels[i]} ))
(( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s' "$marker" "${labels[i]}" "$pad" '' "$exts"
frame+="$row"$'\n'
done
frame+=$'\n'" ${BO_C_DIM}${hint}${BO_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(bo_read_key)" || return "$(( ! touched ))"
case "$key" in
up|k) (( count )) && cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) (( count )) && cursor=$(( (cursor + 1) % count )) ;;
enter|space|right|l)
(( count )) || continue
current="$(bo_program_exts "$cursor")"
current="${current//./}"
printf '\n %s\n' "$(bo_msg "File types for %s, separated by spaces:" "${BO_P_NAME[cursor]}")"
printf ' > '
bo_ui_read_line "$current" || continue
if ! _bo_parse_exts "$BO_LINE_RESULT"; then
bo_bad "$(bo_msg "\"%s\" is not a file extension." "$BO_EXT")"
bo_pause
continue
fi
bo_program_set_exts "$cursor" "${BO_EXTS[@]}" \
|| { bo_bad "$(bo_msg "Could not save the setting.")"; bo_pause; }
touched=1
dirty=1
;;
q|Q|escape|left|h) return "$(( ! touched ))" ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# File types
# ---------------------------------------------------------------------------
# bo_ui_types
# 0 when something changed.
bo_ui_types() {
local key frame row pad i k count cursor=0 dirty=1 touched=0 label shown
local title hint legend warn empty
local l_default l_openwith l_off l_missing l_added
bo_msg_into "File types"; title="$BO_MSG_RESULT"
bo_msg_into "Up/Down select - Space switches - a adds one - Del removes - q goes back"
hint="$BO_MSG_RESULT"
bo_msg_into "\"double-click\" opens it; \"Open With\" only lists the program there."
legend="$BO_MSG_RESULT"
bo_msg_into "File opening is off - this is what it would do."
warn="$BO_MSG_RESULT"
bo_msg_into "No file types yet. Press a to add one."
empty="$BO_MSG_RESULT"
# Resolved once: in the loop, a fork per row per keypress.
bo_msg_into "double-click"; l_default="$BO_MSG_RESULT"
bo_msg_into "Open With"; l_openwith="$BO_MSG_RESULT"
bo_msg_into "off"; l_off="$BO_MSG_RESULT"
bo_msg_into "program not found"; l_missing="$BO_MSG_RESULT"
bo_msg_into "added"; l_added="$BO_MSG_RESULT"
local s_default="${BO_C_GREEN}${l_default}${BO_C_RESET}"
local s_openwith="${BO_C_GREEN}${l_openwith}${BO_C_RESET}"
local s_off="${BO_C_DIM}${l_off}${BO_C_RESET}"
local s_missing="${BO_C_YELLOW}${l_missing}${BO_C_RESET}"
_bo_clearseq
printf '%s\n %s\n' "$BO_CLEARSEQ" "$(bo_msg "Looking at the bottles...")"
bo_config_load
bo_scan_once
_bo_term_rows 11
local top=0
while true; do
if (( dirty )); then
bo_config_load
bo_assoc_compute
count=${#BO_A_ORDER[@]}
(( cursor >= count )) && cursor=$(( count > 0 ? count - 1 : 0 ))
dirty=0
fi
frame="$BO_CLEARSEQ"$'\n'"${BO_C_BOLD}${BO_C_BLUE} ${title}${BO_C_RESET}"$'\n\n'
if (( count == 0 )); then
frame+=" ${BO_C_DIM}${empty}${BO_C_RESET}"$'\n'
fi
_bo_scroll "$cursor" "$top"; top=$BO_TOP
(( top > 0 )) && frame+=" ${BO_C_DIM} ↑${BO_C_RESET}"$'\n'
local marker selected="${BO_C_BLUE}▸${BO_C_RESET} " n=-1
for k in "${BO_A_ORDER[@]}"; do
n=$(( n + 1 ))
(( n < top || n >= top + BO_ROWS )) && continue
i="${BO_A_PIDX[k]}"
if (( i >= 0 )); then
bo_program_label "$i"
label="$BO_LABEL"
else
label="${BO_A_BOTTLE[k]}"
fi
if (( i < 0 )); then
shown="$s_missing"
else
case "${BO_A_STATE[k]}" in
default) shown="$s_default" ;;
openwith) shown="$s_openwith" ;;
*) shown="$s_off" ;;
esac
fi
[[ ${BO_A_SRC[k]} == added ]] && shown+=" ${BO_C_DIM}(${l_added})${BO_C_RESET}"
printf -v row '%-9s %s' ".${BO_A_EXT[k]}" "$label"
pad=$(( 46 - ${#row} ))
(( pad < 0 )) && pad=0
if (( n == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s' "$marker" "$row" "$pad" '' "$shown"
frame+="$row"$'\n'
done
(( top + BO_ROWS < count )) && frame+=" ${BO_C_DIM} ↓${BO_C_RESET}"$'\n'
frame+=$'\n'
# Otherwise it reads as what is on.
if [[ $CFG_ENABLED != yes ]]; then
frame+=" ${BO_C_YELLOW}${warn}${BO_C_RESET}"$'\n'
fi
frame+=" ${BO_C_DIM}${legend}${BO_C_RESET}"$'\n'
frame+=" ${BO_C_DIM}${hint}${BO_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(bo_read_key)" || return "$(( ! touched ))"
k="${BO_A_ORDER[cursor]:-}"
case "$key" in
up|k) (( count )) && cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) (( count )) && cursor=$(( (cursor + 1) % count )) ;;
space|enter|right|l|left|h)
[[ -n $k ]] || continue
(( BO_A_PIDX[k] >= 0 )) || continue
case "${BO_A_STATE[k]}" in
default) bo_assoc_set "$k" openwith ;;
openwith) bo_assoc_set "$k" off ;;
*) bo_assoc_set "$k" default ;;
esac || { bo_bad "$(bo_msg "Could not save the setting.")"; bo_pause; }
touched=1
dirty=1
;;
a|A|+)
bo_ui_add_type && touched=1
dirty=1
;;
delete|backspace|d|D|x|-)
[[ -n $k ]] || continue
bo_assoc_remove "$k" || { bo_bad "$(bo_msg "Could not save the setting.")"; bo_pause; }
touched=1
dirty=1
;;
q|Q|escape) return "$(( ! touched ))" ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# The menu
# ---------------------------------------------------------------------------
bo_ui_menu() {
local choice
bo_ui_term_raw
trap 'bo_ui_term_restore' EXIT INT TERM
while true; do
bo_config_load
# Scan again on every visit.
BO_SCANNED=0
clear 2>/dev/null || true
bo_head " $BO_PRETTY"
bo_ui_status
printf '\n'
printf ' [1] %s\n' "$(bo_msg "Turn file opening on or off")"
printf ' [2] %s\n' "$(bo_msg "Re-apply everything")"
printf ' [3] %s\n' "$(bo_msg "File types")"
printf ' [4] %s\n' "$(bo_msg "Programs")"
printf ' [5] %s\n' "$(bo_msg "Settings")"
printf ' [q] %s\n' "$(bo_msg "Quit")"
printf '\n > '
choice="$(bo_read_key)" || {
printf '\n'; bo_ui_term_restore; trap - EXIT INT TERM; return 0
}
case "$choice" in
enter|space|up|down|left|right|escape|delete|backspace) choice='' ;;
esac
printf '%s\n' "$choice"
case "$choice" in
1)
BO_UI_NEEDS_ACK=''
if [[ $CFG_ENABLED == yes ]]; then
bo_ui_cooked bo_do_disable
else
bo_ui_cooked bo_do_enable
fi
[[ -n $BO_UI_NEEDS_ACK ]] && bo_pause
;;
# The repair: everything taken back and written again.
2)
bo_ui_cooked bo_do_apply --rebuild
bo_pause
;;
# Quietly: the front screen shows the result. Problems wait.
3)
BO_UI_NEEDS_ACK=''
if bo_ui_types && [[ $CFG_ENABLED == yes ]]; then
bo_ui_cooked bo_do_apply --quiet
BO_QUIET=''
fi
[[ -n $BO_UI_NEEDS_ACK ]] && bo_pause
;;
4)
BO_UI_NEEDS_ACK=''
if bo_ui_programs && [[ $CFG_ENABLED == yes ]]; then
bo_ui_cooked bo_do_apply --quiet
BO_QUIET=''
fi
[[ -n $BO_UI_NEEDS_ACK ]] && bo_pause
;;
5)
BO_UI_NEEDS_ACK=''
if bo_ui_settings && [[ $CFG_ENABLED == yes ]]; then
bo_ui_cooked bo_do_apply --quiet
BO_QUIET=''
fi
[[ -n $BO_UI_NEEDS_ACK ]] && bo_pause
;;
q|Q) bo_ui_term_restore; trap - EXIT INT TERM; return 0 ;;
# Redraw. Not Escape: a mistyped arrow key must not quit.
*) ;;
esac
done
}
+316
View File
@@ -0,0 +1,316 @@
# shellcheck shell=bash
#
# File types: what the system already knows, what has to be taught to it, and
# which application opens what.
#
# Launchers claim MIME types, not extensions. An unknown extension is
# octet-stream like every unknown file, so it gets a type of its own here.
# Extension -> MIME type from the compiled globs2 files, minus our own types.
declare -A BO_SYS_MIME=()
BO_SYS_MIME_LOADED=0
bo_mime_system_load() {
local own='' dirs dir files=() ext mime
(( BO_SYS_MIME_LOADED )) && return 0
BO_SYS_MIME_LOADED=1
BO_SYS_MIME=()
# The types defined in our own package, space separated.
if [[ -r $BO_MIMEPKG ]]; then
own=" $(sed -n 's/.*<mime-type type="\([^"]*\)".*/\1/p' "$BO_MIMEPKG" | tr '\n' ' ')"
fi
[[ -r $BO_MIMEDIR/globs2 ]] && files+=("$BO_MIMEDIR/globs2")
IFS=: read -ra dirs <<< "${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
for dir in "${dirs[@]}"; do
[[ -n $dir && -r $dir/mime/globs2 ]] && files+=("$dir/mime/globs2")
done
(( ${#files[@]} )) || return 0
# weight:type:glob[:flags]; plain "*.ext" only, heaviest wins.
while IFS=$'\t' read -r ext mime; do
BO_SYS_MIME[$ext]="$mime"
# Ours can only be in the user's database.
done < <(awk -F: -v own="$own" -v userfile="$BO_MIMEDIR/globs2" '
/^#/ { next }
{
weight = $1 + 0; type = $2; glob = $3
if (FILENAME == userfile && index(own, " " type " ")) next
if (glob !~ /^\*\.[^*?[\]\/]+$/) next
ext = tolower(substr(glob, 3))
if (!(ext in best) || weight > best[ext]) { best[ext] = weight; seen[ext] = type }
}
END { for (e in seen) printf "%s\t%s\n", e, seen[e] }
' "${files[@]}" 2>/dev/null)
}
# bo_mime_for <extension>
# BO_MIME: the type the extension is claimed as. BO_MIME_OWN: 1 when this
# program defines that type.
BO_MIME=''
BO_MIME_OWN=0
bo_mime_for() {
local ext="$1"
bo_mime_system_load
if bo_known_type "$ext" && (( BO_KT_OWN )); then
BO_MIME="$BO_KT_MIME"
BO_MIME_OWN=1
elif [[ -n ${BO_SYS_MIME[$ext]:-} ]]; then
BO_MIME="${BO_SYS_MIME[$ext]}"
BO_MIME_OWN=0
else
bo_known_type "$ext"
BO_MIME="$BO_KT_MIME"
BO_MIME_OWN=1
fi
}
# bo_ext_valid <extension>
# Bottles' own rule.
bo_ext_valid() {
[[ $1 =~ ^[a-z0-9][a-z0-9_+-]{0,31}$ ]]
}
# bo_ext_normalize <input>
# ".FLP", "*.flp" and "flp" all mean the same thing. Result in BO_EXT.
BO_EXT=''
bo_ext_normalize() {
local e="${1,,}"
e="${e#\*}"
e="${e#.}"
BO_EXT="$e"
bo_ext_valid "$e"
}
_bo_xml() {
local s="$1"
s="${s//&/&amp;}"
s="${s//</&lt;}"
s="${s//>/&gt;}"
s="${s//\"/&quot;}"
printf '%s' "$s"
}
# _bo_system_type_xml <MIME type>
# The system's own definition of a type, as update-mime-database wrote it.
_bo_system_type_xml() {
local dir f
local -a dirs
IFS=: read -ra dirs <<< "${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
for dir in "${dirs[@]}"; do
for f in "$dir/mime/$1.xml" "$dir/mime/${1,,}.xml"; do
[[ -r $f ]] && { printf '%s\n' "$f"; return 0; }
done
done
return 1
}
# _bo_magic_xml <spec>
_bo_magic_xml() {
local kind="${1%%:*}" value="${1#*:}"
[[ -n $1 ]] || return 0
printf ' <magic priority="50">\n'
printf ' <match type="%s" offset="0" value="%s"/>\n' "$kind" "$(_bo_xml "$value")"
printf ' </magic>\n'
}
# bo_mime_package
# Every type in BO_PKG_ORDER, from BO_PKG_* set by bo_apply. A system type is
# copied whole with ours added: Qt and GLib read the description from the
# first directory defining the type, and the user's comes first.
bo_mime_package() {
local mime ext extra sysxml
printf '<?xml version="1.0" encoding="UTF-8"?>\n'
printf '<!-- Written by bottles-opener. Taken back by `bottles-opener disable`. -->\n'
printf '<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">\n'
for mime in "${BO_PKG_ORDER[@]}"; do
extra=''
for ext in ${BO_PKG_GLOBS[$mime]:-}; do
extra+=" <glob pattern=\"*.$(_bo_xml "$ext")\"/>"$'\n'
done
extra+="$(_bo_magic_xml "${BO_PKG_MAGIC[$mime]:-}")"
[[ -n ${BO_PKG_MAGIC[$mime]:-} ]] && extra+=$'\n'
if [[ -n ${BO_PKG_ICON[$mime]:-} ]]; then
extra+=" <icon name=\"$(_bo_xml "${BO_PKG_ICON[$mime]}")\"/>"$'\n'
fi
if sysxml="$(_bo_system_type_xml "$mime")"; then
awk -v extra="$extra" '
/^<\?xml/ || /<!--.*-->/ || /^[[:space:]]*<icon[[:space:]]/ { next }
/<\/mime-type>/ { printf "%s", extra; print " " $0; next }
{ print " " $0 }
' "$sysxml"
else
printf ' <mime-type type="%s">\n' "$(_bo_xml "$mime")"
printf ' <comment>%s</comment>\n' "$(_bo_xml "${BO_PKG_DESC[$mime]}")"
printf ' <generic-icon name="%s"/>\n' "$(_bo_xml "${BO_PKG_GICON[$mime]}")"
printf '%s' "$extra"
printf ' </mime-type>\n'
fi
done
printf '</mime-info>\n'
}
# ---------------------------------------------------------------------------
# Icons
# ---------------------------------------------------------------------------
# Files get the icon Windows shows for them, installed into hicolor.
# bo_png_size <file>
# A PNG's width, from its header.
bo_png_size() {
local -a b
read -ra b < <(od -An -tu1 -j0 -N24 -- "$1" 2>/dev/null | tr -s ' \n' ' ')
(( ${#b[@]} >= 24 )) || return 1
# \x89 P N G
(( b[0] == 137 && b[1] == 80 && b[2] == 78 && b[3] == 71 )) || return 1
printf '%d\n' $(( (b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19] ))
}
# bo_icon_target <MIME type> <png>
# BO_ICON_NAME and BO_ICON_TARGET; 1 for an unusable png. Our own name: a theme
# would win over hicolor for the type's standard one.
BO_ICON_TARGET=''
BO_ICON_NAME=''
bo_icon_target() {
local mime="$1" src="$2" size s dir=''
BO_ICON_TARGET=''
[[ $src == /* && -f $src ]] || return 1
size="$(bo_png_size "$src")" || return 1
# The largest theme size the icon still fills.
for s in 16 22 24 32 48 64 128 256 512; do
(( size >= s )) && dir="${s}x${s}"
done
[[ -n $dir ]] || return 1
BO_ICON_NAME="bottles-opener-${mime//\//-}"
BO_ICON_TARGET="$BO_ICONDIR/$dir/mimetypes/$BO_ICON_NAME.png"
}
# bo_icon_png <install> <file> <index>
# The icon as PNG in BO_ICON_PNG; extracted once, then cached.
BO_ICON_PNG=''
bo_icon_png() {
local install="$1" src="$2" idx="${3:-0}" key out rc
BO_ICON_PNG=''
[[ -f $src ]] || return 1
if [[ ${src,,} == *.png ]]; then
BO_ICON_PNG="$src"
return 0
fi
key="$(printf '%s\t%s\t%s' "$src" "$idx" "$(stat -c '%s.%Y' -- "$src" 2>/dev/null)" \
| sha1sum | cut -c1-16)"
out="$BO_CACHEDIR/icons/$key.png"
[[ -s $out ]] && { BO_ICON_PNG="$out"; return 0; }
[[ -e $out.none ]] && return 1
mkdir -p "$BO_CACHEDIR/icons" 2>/dev/null || return 1
rc=127
if bo_have python3; then
python3 "$BO_LIBDIR/icon.py" "$src" "$idx" > "$out.tmp" 2>/dev/null
rc=$?
fi
# The Flatpak surely has icoextract.
if (( rc == 3 || rc == 127 )) && [[ $install == flatpak ]]; then
flatpak run --command=python3 "$BO_FLATPAK_ID" - "$src" "$idx" \
< "$BO_LIBDIR/icon.py" > "$out.tmp" 2>/dev/null
rc=$?
fi
if (( rc == 0 )) && [[ -s $out.tmp ]]; then
mv -f "$out.tmp" "$out"
BO_ICON_PNG="$out"
return 0
fi
rm -f "$out.tmp"
# Remember only a missing icon, not a missing icoextract.
(( rc == 4 )) && : > "$out.none"
return 1
}
# ---------------------------------------------------------------------------
# mimeapps.list
# ---------------------------------------------------------------------------
# Edited line by line: the rest is somebody's settings.
# bo_mimeapps_get <MIME type>
bo_mimeapps_get() {
[[ -r $BO_MIMEAPPS ]] || return 0
awk -v key="$1" '
/^[[:space:]]*\[/ { group = $0; gsub(/^[[:space:]]+|[[:space:]]+$/, "", group); next }
group == "[Default Applications]" {
line = $0
sub(/^[[:space:]]+/, "", line)
if (index(line, key "=") == 1) { print substr(line, length(key) + 2); exit }
}
' "$BO_MIMEAPPS"
}
# bo_mimeapps_set <MIME type> <value>
# An empty value takes the line out.
bo_mimeapps_set() {
local key="$1" value="$2" tmp file
mkdir -p "$(dirname "$BO_MIMEAPPS")" || return 1
[[ -e $BO_MIMEAPPS ]] || : > "$BO_MIMEAPPS" || return 1
# Keep a dotfiles symlink: replace its target, not the link.
file="$(readlink -f -- "$BO_MIMEAPPS")" || return 1
tmp="$(mktemp "${file}.XXXXXX")" || return 1
chmod --reference="$file" "$tmp" 2>/dev/null || chmod 0644 "$tmp"
awk -v key="$key" -v value="$value" '
function flush() {
if (ingroup && !done && value != "") { print key "=" value; done = 1 }
}
/^[[:space:]]*\[/ {
flush()
group = $0; gsub(/^[[:space:]]+|[[:space:]]+$/, "", group)
ingroup = (group == "[Default Applications]")
if (ingroup) seen = 1
print; next
}
ingroup {
line = $0
sub(/^[[:space:]]+/, "", line)
if (index(line, key "=") == 1) {
if (!done && value != "") { print key "=" value; done = 1 }
next
}
}
{ print }
END {
flush()
if (!seen && value != "") {
print "[Default Applications]"
print key "=" value
}
}
' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
mv -f "$tmp" "$file"
}
# bo_is_ours <mimeapps value>
# Whether it names only one of our launchers.
bo_is_ours() {
local v="${1%;}"
[[ $v == bottles-opener-*.desktop && $v != *';'* ]]
}
+237
View File
@@ -0,0 +1,237 @@
# shellcheck shell=bash
#
# Opening a file: what a launcher runs on a double click.
#
# The file is handed over as Explorer would: a Windows path (C:\ inside the
# bottle, Z:\ for the rest), since "/home/..." reads like a switch to many
# programs. The Flatpak gets it through the document portal first.
# bo_uri_to_path <argument>
# file:// URI to path, in BO_PATH. Returns 1 for anything not local.
BO_PATH=''
bo_uri_to_path() {
local s="$1"
case "$s" in
file://*)
s="${s#file://}"
# localhost is local; other hosts are not.
if [[ $s == localhost/* ]]; then
s="/${s#localhost/}"
elif [[ $s != /* ]]; then
BO_PATH="$1"
return 1
fi
# Percent-decoding; backslashes doubled for printf %b.
s="${s//\\/\\\\}"
s="${s//%/\\x}"
printf -v s '%b' "$s"
;;
esac
BO_PATH="$s"
}
# bo_winpath <bottle directory> <absolute path>
# The path through the drive with the longest matching target, in BO_WINPATH.
BO_WINPATH=''
bo_winpath() {
local bdir="$1" path="$2" dev target letter best='' best_target='' best_len=-1 len rest
BO_WINPATH=''
for dev in "$bdir"/dosdevices/?:; do
[[ -e $dev ]] || continue
target="$(readlink -f -- "$dev")" || continue
if [[ $target == / ]]; then
len=0
elif [[ $path == "$target" || $path == "$target"/* ]]; then
len=${#target}
else
continue
fi
if (( len > best_len )); then
best_len=$len
best="$dev"
best_target="$target"
fi
done
[[ -n $best ]] || return 1
letter="${best##*/}"
letter="${letter%:}"
if [[ $best_target == / ]]; then
rest="$path"
else
rest="${path#"$best_target"}"
fi
rest="${rest//\//\\}"
[[ $rest == \\* ]] || rest="\\$rest"
BO_WINPATH="${letter^^}:$rest"
}
# bo_open_fail <message>
# On the terminal, or as a notification when started from a file manager.
bo_open_fail() {
printf '%s: %s\n' "$BO_NAME" "$1" >&2
if [[ ! -t 2 ]] && bo_have notify-send; then
notify-send --app-name="$BO_PRETTY" --icon=dialog-error \
"$BO_PRETTY" "$1" > /dev/null 2>&1 || true
fi
return 1
}
# bo_cli_has_program_id <install>
# Whether bottles-cli knows --program-id (older ones only names). Read, not
# run: running it costs a second and a half per double click.
bo_cli_has_program_id() {
local cli loc
case "$1" in
native)
cli="$(command -v bottles-cli)" || return 1
;;
flatpak)
loc="$(flatpak info --show-location "$BO_FLATPAK_ID" 2>/dev/null)" || return 1
cli="$loc/files/bin/bottles-cli"
;;
esac
[[ -r $cli ]] && grep -q -- '--program-id' "$cli" 2>/dev/null
}
# _bo_win_args <template> <windows path>
# The arguments Windows would pass, in BO_WARGS: the registry's template with
# the file for %1. Office's /o "%u" and other placeholders are dropped. A
# template without %1 relies on DDE (Excel's /dde): then only the file.
BO_WARGS=()
_bo_win_args() {
local t="$1" file="$2" c j cur='' have=0 quoted=0 placed=0 tok last
local -a toks=()
for (( j = 0; j < ${#t}; j++ )); do
c="${t:j:1}"
if [[ $c == '"' ]]; then
quoted=$(( ! quoted )); have=1
elif [[ $c == [[:space:]] ]] && (( ! quoted )); then
(( have )) && toks+=("$cur")
cur=''; have=0
else
cur+="$c"; have=1
fi
done
(( have )) && toks+=("$cur")
BO_WARGS=()
for tok in "${toks[@]}"; do
if [[ $tok == *%[1lLvV]* ]]; then
tok="${tok//%1/"$file"}"; tok="${tok//%[lL]/"$file"}"; tok="${tok//%[vV]/"$file"}"
BO_WARGS+=("$tok")
placed=1
elif [[ $tok == *%[uU]* ]]; then
# ...and the switch in front of it.
last=$(( ${#BO_WARGS[@]} - 1 ))
if (( last >= 0 )) && [[ ${BO_WARGS[last]} == [/-]* && ${BO_WARGS[last]} != *%* ]]; then
unset "BO_WARGS[last]"
BO_WARGS=("${BO_WARGS[@]}")
fi
elif [[ $tok == *%[*0-9A-Za-z~]* ]]; then
continue
else
BO_WARGS+=("$tok")
fi
done
(( placed )) || BO_WARGS=("$file")
}
# bo_do_open <launcher id> [file...]
bo_do_open() {
local id="${1:-}" line install pid bottle bdir pname arg path ext template
local -a winargs=() cmd=()
shift || true
if [[ ! $id =~ ^[0-9a-f]{12}$ ]]; then
bo_open_fail "$(bo_msg "Usage: bottles-opener open <launcher> [file...]")"
return 1
fi
line="$(awk -F'\t' -v id="$id" '$1 == id { print; exit }' "$BO_LAUNCHERS" 2>/dev/null)"
if [[ -z $line ]]; then
bo_open_fail "$(bo_msg "This launcher is out of date. Run \"bottles-opener apply\" and try again.")"
return 1
fi
IFS=$'\t' read -r _ install pid bottle bdir pname <<< "$line"
for arg in "$@"; do
# Not a local file: passed as it is.
if ! bo_uri_to_path "$arg"; then
winargs+=("$arg")
continue
fi
path="$BO_PATH"
[[ $path == /* ]] || path="$PWD/$path"
if [[ ! -e $path ]]; then
bo_open_fail "$(bo_msg "The file %s does not exist." "$path")"
return 1
fi
path="$(readlink -f -- "$path")"
if [[ $install == flatpak && $path != "$bdir"/* ]]; then
# Writable, so the program can save it.
if ! path="$(flatpak document-export --app="$BO_FLATPAK_ID" \
--allow-read --allow-write -- "$path" 2>/dev/null)" || [[ -z $path ]]; then
bo_open_fail "$(bo_msg "Could not hand %s to the Bottles Flatpak." "$arg")"
return 1
fi
fi
if ! bo_winpath "$bdir" "$path"; then
bo_open_fail "$(bo_msg "No drive in the bottle \"%s\" reaches %s." "$bottle" "$path")"
return 1
fi
# With the switches Windows would use for this extension.
ext="${path##*/}"
ext="${ext##*.}"
template=''
[[ $path == */*.* ]] && template="$(awk -F'\t' -v id="$id" -v e="${ext,,}" \
'$1 == id && $2 == e { print $3; exit }' "$BO_COMMANDS" 2>/dev/null)"
_bo_win_args "$template" "$BO_WINPATH"
winargs+=("${BO_WARGS[@]}")
done
case "$install" in
native) cmd=(bottles-cli) ;;
flatpak) cmd=(flatpak run --command=bottles-cli "$BO_FLATPAK_ID") ;;
*)
bo_open_fail "$(bo_msg "This launcher is out of date. Run \"bottles-opener apply\" and try again.")"
return 1
;;
esac
cmd+=(run -b "$bottle")
if bo_cli_has_program_id "$install"; then
cmd+=(--program-id "$pid")
else
cmd+=(-p "$pname")
fi
cmd+=(-- "${winargs[@]}")
if [[ -n ${BO_DRY_RUN:-} ]]; then
printf '%q ' "${cmd[@]}"
printf '\n'
return 0
fi
exec "${cmd[@]}"
}
+452
View File
@@ -0,0 +1,452 @@
#!/usr/bin/env python3
#
# Lists the bottles and programs of one Bottles installation, and the file
# types each program registered in its bottle's registry - the way Windows
# knows them: extension -> ProgID -> open command, icon and description.
#
# Runs on the host, or inside the Bottles Flatpak fed through stdin, so it only
# needs what Bottles itself needs: Python and PyYAML.
#
# Usage: scan.py <data home> [cache dir]
#
# Output, one record per line, fields separated by \x1f:
# R <bottles directory>
# B <bottle name> <bottle directory> <bottle.yml>
# P <bottle name> <program id> <program name> <windows path> <icon>
# A <bottle name> <program id> <ext> <args> <icon file> <icon index> <description>
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
import hashlib
import json
import os
import re
import sys
try:
import yaml
except ImportError:
sys.exit(3)
LOADER = getattr(yaml, "CSafeLoader", yaml.SafeLoader)
SEP = "\x1f"
CACHE_VERSION = 2
EXT_RE = re.compile(r"^[a-z0-9][a-z0-9_+-]{0,31}$")
# Registered by half of all installers, and never a document.
SKIP_EXTS = {
"exe", "dll", "lnk", "bat", "cmd", "com", "msi", "msp", "scr", "pif",
"url", "cpl", "sys", "ocx", "drv", "reg", "inf", "ini", "cab",
}
def load(path):
try:
with open(path, encoding="utf-8") as f:
return yaml.load(f, Loader=LOADER)
except (OSError, UnicodeDecodeError, yaml.YAMLError):
return None
def field(value):
text = "" if value is None else str(value)
for ch in ("\n", "\r", "\t", SEP):
text = text.replace(ch, " ")
return text
def emit(*values):
sys.stdout.write(SEP.join(field(v) for v in values) + "\n")
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
def _unescape(s):
out, i = [], 0
while i < len(s):
c = s[i]
if c == "\\" and i + 1 < len(s):
n = s[i + 1]
if n == "x":
m = re.match(r"[0-9a-fA-F]{1,4}", s[i + 2:])
if m:
out.append(chr(int(m.group(0), 16)))
i += 2 + len(m.group(0))
continue
out.append({"n": "\n", "r": "\r", "t": "\t", "0": "\0"}.get(n, n))
i += 2
continue
out.append(c)
i += 1
return "".join(out)
def _string(raw):
"""A value's text, for the string kinds; None for anything else."""
if raw.startswith('"') and raw.endswith('"'):
return _unescape(raw[1:-1])
if raw.startswith('str(2):"') and raw.endswith('"'):
return _unescape(raw[8:-1])
if raw.startswith("hex(2):"):
try:
data = bytes(int(b, 16) for b in raw[7:].replace(" ", "").split(",") if b)
return data.decode("utf-16-le").rstrip("\0")
except ValueError:
return None
return None
def read_classes(path):
"""Software\\Classes of one hive: {lowercase subkey: {lowercase name: text}}."""
classes = {}
prefix = "software\\classes\\"
current = None
pending = None
try:
f = open(path, encoding="utf-8", errors="replace")
except OSError:
return classes
with f:
for line in f:
line = line.rstrip("\n")
# hex values run on over lines ending in a backslash
if pending is not None:
pending[1] += line.strip().rstrip("\\")
if not line.endswith("\\"):
name, raw = pending
pending = None
text = _string(raw)
if current is not None and text is not None:
classes.setdefault(current, {})[name] = text
continue
if line.startswith("["):
end = line.rfind("]")
key = _unescape(line[1:end]).lower() if end > 0 else ""
current = key[len(prefix):] if key.startswith(prefix) else None
# A key exists even with no values, and so do its parents.
if current:
parts = current.split("\\")
for n in range(1, len(parts) + 1):
classes.setdefault("\\".join(parts[:n]), {})
continue
if current is None or not line or line[0] not in '@"':
continue
if line.startswith("@="):
name, raw = "", line[2:]
else:
m = re.match(r'"((?:[^"\\]|\\.)*)"=(.*)$', line)
if not m:
continue
name, raw = _unescape(m.group(1)).lower(), m.group(2)
if raw.startswith("hex(2):") and raw.endswith("\\"):
pending = [name, raw.rstrip("\\")]
continue
text = _string(raw)
if text is not None:
classes.setdefault(current, {})[name] = text
return classes
def expand(text, users):
"""The environment variables installers put into paths."""
user = f"C:\\users\\{users}"
table = {
"programfiles": "C:\\Program Files",
"programw6432": "C:\\Program Files",
"programfiles(x86)": "C:\\Program Files (x86)",
"commonprogramfiles": "C:\\Program Files\\Common Files",
"commonprogramw6432": "C:\\Program Files\\Common Files",
"commonprogramfiles(x86)": "C:\\Program Files (x86)\\Common Files",
"systemroot": "C:\\windows",
"windir": "C:\\windows",
"systemdrive": "C:",
"programdata": "C:\\ProgramData",
"allusersprofile": "C:\\ProgramData",
"public": "C:\\users\\Public",
"userprofile": user,
"appdata": user + "\\AppData\\Roaming",
"localappdata": user + "\\AppData\\Local",
}
return re.sub(
r"%([^%]+)%",
lambda m: table.get(m.group(1).lower(), m.group(0)),
text,
)
def split_command(cmd):
"""(executable, rest) of a command line."""
cmd = cmd.strip()
if cmd.startswith('"'):
end = cmd.find('"', 1)
if end < 0:
return cmd[1:], ""
return cmd[1:end], cmd[end + 1:].strip()
m = re.match(r"(.*?\.(?:exe|com|bat|cmd))(?:\s+(.*))?$", cmd, re.I)
if m:
return m.group(1), (m.group(2) or "").strip()
exe, _, rest = cmd.partition(" ")
return exe, rest.strip()
def to_unix(bottle_dir, winpath):
"""A Windows path inside the bottle, found case-insensitively like Windows would."""
winpath = winpath.strip().strip('"')
if winpath.startswith("/"):
return winpath if os.path.exists(winpath) else None
m = re.match(r"^([A-Za-z]):[\\/]*(.*)$", winpath)
if not m:
return None
path = os.path.realpath(os.path.join(bottle_dir, "dosdevices", m.group(1).lower() + ":"))
for part in re.split(r"[\\/]+", m.group(2)):
if not part:
continue
candidate = os.path.join(path, part)
if not os.path.exists(candidate):
try:
lower = part.lower()
match = next(e for e in os.listdir(path) if e.lower() == lower)
except (OSError, StopIteration):
return None
candidate = os.path.join(path, match)
path = candidate
return path
def registered_types(bottle_dir, users):
"""{ext: (exe, args, icon file, icon index, description)} from both hives."""
system = read_classes(os.path.join(bottle_dir, "system.reg"))
user = read_classes(os.path.join(bottle_dir, "user.reg"))
def values(key):
merged = dict(system.get(key, {}))
merged.update(user.get(key, {}))
return merged
def default(key):
return values(key).get("", "").strip()
def has(key):
return key in system or key in user
keys = set(system) | set(user)
result = {}
for key in keys:
if not key.startswith(".") or "\\" in key:
continue
ext = key[1:]
if not EXT_RE.match(ext) or ext in SKIP_EXTS:
continue
progid = default(key).lower()
if not progid or not has(progid):
continue
curver = default(progid + "\\curver").lower()
if curver and has(curver):
progid = curver
# The default verb, else open, else any.
verb = default(progid + "\\shell").split(",")[0].strip().lower()
if not verb or not has(f"{progid}\\shell\\{verb}\\command"):
verb = "open"
command = default(f"{progid}\\shell\\{verb}\\command")
if not command:
verbs = sorted(
k for k in keys
if k.startswith(progid + "\\shell\\") and k.endswith("\\command")
)
command = default(verbs[0]) if verbs else ""
if not command:
continue
exe, args = split_command(expand(command, users))
icon_file, icon_index = "", "0"
icon = expand(default(progid + "\\defaulticon"), users).strip()
if icon and "%1" not in icon:
icon = icon.strip('"')
m = re.match(r'^"?(.*?)"?\s*,\s*(-?\d+)$', icon)
if m:
icon, icon_index = m.group(1), m.group(2)
icon_file = to_unix(bottle_dir, icon) or ""
desc = ""
for candidate in (values(progid).get("friendlytypename", ""), default(progid)):
candidate = candidate.strip()
if candidate and not candidate.startswith("@"):
desc = candidate
break
result[ext] = (exe, args, icon_file, icon_index, desc)
return result
def cached_types(bottle_dir, users, cache_dir):
"""registered_types, kept across runs until either hive changes."""
stamp = []
for hive in ("system.reg", "user.reg"):
try:
st = os.stat(os.path.join(bottle_dir, hive))
stamp.append([st.st_size, st.st_mtime_ns])
except OSError:
stamp.append(None)
cache = None
if cache_dir:
name = hashlib.sha1(bottle_dir.encode()).hexdigest()[:16] + ".json"
cache = os.path.join(cache_dir, name)
try:
with open(cache, encoding="utf-8") as f:
data = json.load(f)
if data.get("version") == CACHE_VERSION and data.get("stamp") == stamp:
return {k: tuple(v) for k, v in data["types"].items()}
except (OSError, ValueError, KeyError, AttributeError):
pass
types = registered_types(bottle_dir, users)
if cache:
try:
os.makedirs(cache_dir, exist_ok=True)
tmp = cache + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"version": CACHE_VERSION, "stamp": stamp, "types": types}, f)
os.replace(tmp, cache)
except OSError:
pass
return types
def windows_user(bottle_dir):
try:
names = os.listdir(os.path.join(bottle_dir, "drive_c", "users"))
except OSError:
return "user"
names = [n for n in names if n.lower() not in ("public", "default", "default user", "all users")]
return sorted(names)[0] if names else "user"
# ---------------------------------------------------------------------------
# Bottles
# ---------------------------------------------------------------------------
def main():
if len(sys.argv) < 2:
return 2
cache_dir = sys.argv[2] if len(sys.argv) > 2 else ""
base = os.path.join(sys.argv[1], "bottles")
root = os.path.join(base, "bottles")
data = load(os.path.join(base, "data.yml"))
if isinstance(data, dict):
custom = data.get("custom_bottles_path")
if (
isinstance(custom, str)
and custom
and not ("/run/user/" in custom and "/doc/" in custom)
and os.path.isdir(custom)
and os.access(custom, os.W_OK)
):
root = custom
emit("R", root)
try:
entries = sorted(os.listdir(root))
except OSError:
return 0
for entry in entries:
bottle_dir = os.path.join(root, entry)
if not os.path.isdir(bottle_dir):
continue
config_path = os.path.join(bottle_dir, "bottle.yml")
placeholder = os.path.join(bottle_dir, "placeholder.yml")
if os.path.exists(placeholder):
target = load(placeholder)
if not isinstance(target, dict) or not target.get("Path"):
continue
bottle_dir = str(target["Path"])
config_path = os.path.join(bottle_dir, "bottle.yml")
config = load(config_path)
if not isinstance(config, dict) or config.get("Environment") == "Steam":
continue
name = config.get("Name") or entry
emit("B", name, bottle_dir, config_path)
programs = config.get("External_Programs") or {}
if not isinstance(programs, dict):
continue
listed = []
for key, program in programs.items():
if not isinstance(program, dict) or program.get("removed"):
continue
path = program.get("path")
if not path:
continue
listed.append((
str(program.get("name") or program.get("executable") or key),
str(program.get("id") or key),
str(path),
program.get("icon") or "",
))
listed.sort(key=lambda p: p[0].lower())
for prog_name, prog_id, path, icon in listed:
emit("P", name, prog_id, prog_name, path, icon)
if not listed:
continue
# Which program each registered type belongs to: by its path, as
# Windows spells it or as it is on disk, and by file name when that
# names exactly one program.
by_path, by_real, by_base = {}, {}, {}
for _, prog_id, path, _ in listed:
by_path[path.lower().replace("/", "\\")] = prog_id
real = to_unix(bottle_dir, path)
if real:
by_real[os.path.realpath(real)] = prog_id
base_name = re.split(r"[\\/]", path)[-1].lower()
by_base.setdefault(base_name, []).append(prog_id)
types = cached_types(bottle_dir, windows_user(bottle_dir), cache_dir)
for ext in sorted(types):
exe, args, icon_file, icon_index, desc = types[ext]
prog_id = by_path.get(exe.lower().replace("/", "\\"))
if not prog_id:
real = to_unix(bottle_dir, exe)
prog_id = by_real.get(os.path.realpath(real)) if real else None
if not prog_id:
hits = by_base.get(re.split(r"[\\/]", exe)[-1].lower(), [])
prog_id = hits[0] if len(hits) == 1 else None
if prog_id:
emit("A", name, prog_id, ext, args, icon_file, icon_index, desc)
return 0
if __name__ == "__main__":
sys.exit(main())
+179
View File
@@ -0,0 +1,179 @@
# shellcheck shell=bash
#
# Finding Bottles, its bottles and the programs in them.
#
# Native (bottles-cli, ~/.local/share/bottles) and Flatpak (~/.var/app/...),
# possibly both. Everything is tagged with its install: it decides how a
# program is started.
# The installations present, "native" and/or "flatpak". Filled once per run.
BO_INSTALLS=()
BO_INSTALLS_DONE=0
bo_installs_detect() {
(( BO_INSTALLS_DONE )) && return 0
BO_INSTALLS_DONE=1
BO_INSTALLS=()
# For testing against a fixed set; not documented anywhere else.
if [[ -n ${BO_FORCE_INSTALLS+set} ]]; then
read -ra BO_INSTALLS <<< "$BO_FORCE_INSTALLS"
return 0
fi
bo_have bottles-cli && BO_INSTALLS+=(native)
if bo_have flatpak && flatpak info "$BO_FLATPAK_ID" > /dev/null 2>&1; then
BO_INSTALLS+=(flatpak)
fi
return 0
}
bo_install_present() {
local i
bo_installs_detect
for i in "${BO_INSTALLS[@]}"; do
[[ $i == "$1" ]] && return 0
done
return 1
}
# bo_install_data <install>
# The directory holding bottles/ for that installation.
bo_install_data() {
case "$1" in
native) printf '%s\n' "${BO_NATIVE_DATA:-$BO_XDG_DATA}" ;;
flatpak) printf '%s\n' "${BO_FLATPAK_DATA:-$HOME/.var/app/$BO_FLATPAK_ID/data}" ;;
esac
}
# ---------------------------------------------------------------------------
# The scan
# ---------------------------------------------------------------------------
BO_ROOTS=() # every bottles directory, for the watcher
BO_CONFS=() # every bottle.yml, for the watcher
BO_B_INSTALL=() # one entry per bottle
BO_B_NAME=()
BO_P_INSTALL=() # one entry per program
BO_P_BOTTLE=()
BO_P_BDIR=()
BO_P_ID=()
BO_P_NAME=()
BO_P_PATH=()
BO_P_ICON=()
BO_R_INSTALL=() # one entry per type a program registered in its bottle
BO_R_PID=()
BO_R_EXT=()
BO_R_ARGS=()
BO_R_ICON=()
BO_R_IDX=()
BO_R_DESC=()
declare -A BO_R_AT=() # ext \x1f install \x1f program id -> index into BO_R_*
declare -A BO_P_AT=() # install \x1f program id -> index into BO_P_*
# An install that could not be read, rather than one that is empty.
BO_SCAN_ERROR=''
BO_SCANNED=0
# _bo_scan_run <install> <data home>
# The host's Python first; the Flatpak's, which surely has PyYAML, as fallback.
_bo_scan_run() {
local install="$1" data="$2" rc=127
if bo_have python3; then
python3 "$BO_LIBDIR/scan.py" "$data" "$BO_CACHEDIR/registry"
rc=$?
fi
if (( rc == 3 || rc == 127 )) && [[ $install == flatpak ]]; then
flatpak run --command=python3 "$BO_FLATPAK_ID" - "$data" \
< "$BO_LIBDIR/scan.py" 2>/dev/null
rc=$?
fi
return "$rc"
}
bo_scan() {
local install data kind a b c d e f g out bdir
BO_ROOTS=(); BO_CONFS=()
BO_B_INSTALL=(); BO_B_NAME=()
BO_P_INSTALL=(); BO_P_BOTTLE=(); BO_P_BDIR=(); BO_P_ID=()
BO_P_NAME=(); BO_P_PATH=(); BO_P_ICON=()
BO_R_INSTALL=(); BO_R_PID=(); BO_R_EXT=(); BO_R_ARGS=()
BO_R_ICON=(); BO_R_IDX=(); BO_R_DESC=(); BO_R_AT=(); BO_P_AT=()
BO_SCAN_ERROR=''
bo_installs_detect
for install in "${BO_INSTALLS[@]}"; do
data="$(bo_install_data "$install")"
if ! out="$(_bo_scan_run "$install" "$data")"; then
BO_SCAN_ERROR="$install"
continue
fi
declare -A dirs=()
while IFS=$'\x1f' read -r kind a b c d e f g; do
case "$kind" in
R)
BO_ROOTS+=("$a")
;;
B)
BO_B_INSTALL+=("$install"); BO_B_NAME+=("$a")
BO_CONFS+=("$c")
dirs[$a]="$b"
;;
P)
bdir="${dirs[$a]:-}"
[[ -n $bdir && -n $b ]] || continue
BO_P_AT[$install$'\x1f'$b]=${#BO_P_ID[@]}
BO_P_INSTALL+=("$install"); BO_P_BOTTLE+=("$a")
BO_P_BDIR+=("$bdir"); BO_P_ID+=("$b")
BO_P_NAME+=("$c"); BO_P_PATH+=("$d"); BO_P_ICON+=("$e")
;;
A)
BO_R_AT[$c$'\x1f'$install$'\x1f'$b]=${#BO_R_EXT[@]}
BO_R_INSTALL+=("$install"); BO_R_PID+=("$b"); BO_R_EXT+=("$c")
BO_R_ARGS+=("$d"); BO_R_ICON+=("$e"); BO_R_IDX+=("$f")
BO_R_DESC+=("$g")
;;
esac
done <<< "$out"
unset dirs
done
BO_SCANNED=1
return 0
}
bo_scan_once() {
(( BO_SCANNED )) || bo_scan
}
# bo_program_find <install> <program id>
# Index into the BO_P_* arrays, in BO_FOUND; returns 1 when there is none.
BO_FOUND=-1
bo_program_find() {
BO_FOUND="${BO_P_AT[$1$'\x1f'$2]:--1}"
(( BO_FOUND >= 0 ))
}
# bo_program_label <index>
# "FL Studio · FL Studio", plus "(Flatpak)" when both installs exist.
BO_LABEL=''
bo_program_label() {
local i="$1"
BO_LABEL="${BO_P_NAME[i]} · ${BO_P_BOTTLE[i]}"
if [[ ${BO_P_INSTALL[i]} == flatpak ]] && bo_install_present native; then
BO_LABEL+=" (Flatpak)"
fi
}