Compare commits

..
48 Commits
Author SHA1 Message Date
dms-ci[bot] aa4b99def4 bump VERSION to v1.6.1 2026-09-08 17:14:24 +00:00
Zoey Bauer 4015f634c4 add support for shelly (#3367)
* add support for shelly

* fixed terminal failure

* updated to have it not allow adding ignore aur

* updated error strings

* Fixed label

port 1.6

(cherry picked from commit 8fe918ff24)
2026-09-08 15:50:35 +00:00
bbedward b2d1ec6690 systray: dont wrap context menu title
port 1.6

(cherry picked from commit 2bf2754ad2)
2026-09-08 14:19:25 +00:00
Huỳnh Thiện Lộc 1efc10dc7e fix(screenshot): force full-damage commit on cursor toggle (#3365)
* fix(screenshot): force full-damage commit on cursor toggle with compositor v4

* test(screenshot): drop tautological cursor-mismatch test

(cherry picked from commit 0f058468a5)
2026-09-08 09:24:09 -04:00
BB 94710cf643 feat: support for Aqueous Compositor (#3357)
* updated dank to support aqueous

* further updates

* aqueous support work

* updated to support workspace options better in aqueous

* added blur to dms shell for aqueous

* added keybinding update

* remove unneeded blur

* clean up per claud review

* swapped to ipc

* aqueous: move IPC integration to QML, delete test fixtures (since we
dont have a QML test structure even setup), delete in-repo docs

* fix staticcheck

* aqueous: consolidate more aqueous specific stuff

* add aqueous logo

---------

Co-authored-by: Zoey <[email protected]>
(cherry picked from commit b9365610c8)
2026-09-08 09:23:54 -04:00
bbedward 3711cb11fa core: bump dankgo
fixes #3361
port 1.6

(cherry picked from commit 8232144bb7)
2026-09-08 01:19:29 +00:00
bbedward 40a0eb6841 workspace: keep hit area out of adjacent spacers
fixes #3360
port 1.6

(cherry picked from commit fb07a91108)
2026-09-08 01:19:27 +00:00
bbedward 0a643028b3 hyprland: stop overwriting the config backup during lua migration
fixes #3359
port 1.6

(cherry picked from commit 24e17a5957)
2026-09-08 01:02:22 +00:00
bbedward 0189eb0dbb frame: draw panel backgrounds when fullscreen hides the frame
port 1.6

(cherry picked from commit 17d4ea6f88)
2026-09-08 00:45:35 +00:00
bbedward ed26bdebdc i18n: sync, change plugin workflow
port 1.6

(cherry picked from commit 8c42700a4c)
2026-09-08 00:45:32 +00:00
bbedward 2e2c74e355 i18n: sync
port 1.6

(cherry picked from commit 9bf8174984)
2026-09-07 17:51:21 +00:00
bbedward c1a3be60c3 fix(launcher): stop injectnig plugins into app categories
port 1.6

(cherry picked from commit 0f56536142)
2026-09-07 14:37:09 +00:00
bbedward f8260513b1 frame: keep overlay backgrounds with their content
fixes #3350
port 1.6

(cherry picked from commit de27853c46)
2026-09-07 14:29:53 +00:00
bbedward ed1855946f ci: disable qml-common from release fanout 2026-09-07 10:01:55 -04:00
bbedward c0bc1d8885 display: watch the session store for active profile changes
port 1.6

(cherry picked from commit 0890db93db)
2026-09-07 13:55:38 +00:00
Huỳnh Thiện Lộc 3f09d7b1c8 feat(screenshot): add touch & stylus support for region capture (#3353)
* feat(screenshot): add touch and stylus input support for region capture

* fix(screenshot): guard against finishSelection on touch up during scroll phase

port 1.6

(cherry picked from commit a9fce46fec)
2026-09-07 13:11:50 +00:00
bbedward 710f20d085 i18n: make sync script auto-pull plugins for translation
port 1.6

(cherry picked from commit c1e53dbc33)
2026-09-06 21:05:10 +00:00
bbedward 6261e580aa bar: refresh blur region after widget layout settles
ref #3348
port 1.6

(cherry picked from commit 62ad4c2cb7)
2026-09-06 17:54:19 +00:00
bbedward 42912ebe74 launcher: stop rebuilding the category map per app and index i18n misses
fixes #3345
port 1.6

(cherry picked from commit c76c29139f)
2026-09-06 17:40:35 +00:00
bbedward e8a755b85d controlcenter: release dgop refs while the popout is closed
ref #3349

(cherry picked from commit 654c6dce8f)
2026-09-06 13:40:26 -04:00
bbedward 6713f05e02 audio: add output card selection
port 1.6

(cherry picked from commit 8188f53da5)
2026-09-06 17:15:06 +00:00
bbedward 5d39702136 lock: keep the password cursor inside the field and follow it through overflow and wrapped reveals
fixes #3319
port 1.6

(cherry picked from commit 5baef07048)
2026-09-05 16:26:30 +00:00
bbedward 166b82df1b network: reject weak hotspot passwords before handing them to NetworkManager
fixes #3318
port 1.6

(cherry picked from commit f402731ef2)
2026-09-05 16:21:54 +00:00
bbedward bca1466162 dash, notifications, island: fix calendar banner overflow, expand chevron, and player row clipping
fixes #3300
fixes #3301
fixes #3317
port 1.6

(cherry picked from commit 59f6f1fa71)
2026-09-05 16:21:52 +00:00
bbedward 75b8932b20 network: keep saved wifi profiles when NM fails with no-secrets
fixes #3313
port 1.6

(cherry picked from commit c0f775ae49)
2026-09-05 16:21:49 +00:00
bbedward a1b51b191f bar: focus workspace icons on any workspace and wire island overview loader
fixes #3309
fixes #3310

port 1.6

(cherry picked from commit 3d6e45f430)
2026-09-05 15:40:15 +00:00
bbedward 5defc26929 display config: judge profile outputs by live heads, not stored make/model
fixes #3306
port 1.6

(cherry picked from commit 85bc7604c2)
2026-09-05 15:40:12 +00:00
bbedward 71e8a85cd1 screenshot: resolve region and picker keys through the seat keymap
fixes #3311
port 1.6

(cherry picked from commit 3674d0f480)
2026-09-05 15:19:16 +00:00
Huỳnh Thiện Lộc 95b2750e5f fix(screenshot): scale HUD and selection label for HiDPI displays (#3308)
port 1.6

(cherry picked from commit 5f124de8d4)
2026-09-05 15:10:48 +00:00
bbedward 7f1a423174 theme: read the dynamic error color per mode
fixes #3298
port 1.6

(cherry picked from commit 59a03f450d)
2026-09-05 14:47:12 +00:00
bbedward 33e599100c systemd: keep the inherited hard file limit in dms.service
fixes #3312
port 1.6

(cherry picked from commit 3797422832)
2026-09-05 14:23:00 +00:00
Adolfo Ochagavía cb85620777 Fix repeated day in calendar (#3302)
Root cause: we were creating a `Date` that did not exist in my timezone
(Santiago, Chile) because of DST. The JS engine decided to pick the
previous day, so I ended up with two times 5 September.

Fix: introduce a `DateOnly` object that internally uses UTC (to prevent
DST issues). Other parts of the system still use `Date`, which
probably should be refactored to use `DateOnly` as well (maybe in an
upcoming PR?). In the meantime, we provide a function to export a
`DateOnly` as a `Date`, using noon as the time instead of midnight
(because there are no DST changes at noon AFAIK).

We tested this manually (see the attached screenshots), but didn't find
a suitable place for automated tests.

port 1.6

(cherry picked from commit e4793327b8)
2026-09-04 23:42:17 +00:00
purian23 faf25e98a5 feat(island): vertical orientation layout management support
Port 1.6

(cherry picked from commit e18931353b)
2026-09-04 21:57:39 +00:00
bbedward d7b264701e bar: keep the widget font size the user asked for
ref #3295
port 1.6

(cherry picked from commit cf3c76a79f)
2026-09-04 17:11:05 +00:00
bbedward c929a8952b screenshot: add instancelock on runtimedir/wayland-display to prevent
multiple instances from running

fixes #3293
port 1.6

(cherry picked from commit 7e18a68b82)
2026-09-04 17:04:14 +00:00
bbedward 82089c588a launcher: revalidate the cached launcher sections on load
fixes #3292
port 1.6

(cherry picked from commit 96a21fbe08)
2026-09-04 16:02:55 +00:00
bbedward 19a56d1835 clock: align the horizontal time block to whole pixels
fixes #3291
port 1.6

(cherry picked from commit fbffa410af)
2026-09-04 16:02:53 +00:00
bbedward 8371fa8ef5 audio: resolve the sound theme before the login sound plays
fixes #3290
port 1.6

(cherry picked from commit 936b42b4e7)
2026-09-04 16:02:51 +00:00
Huỳnh Thiện Lộc acca7fae68 fix(dash): dim today button when on current month (#3294)
* fix(dash): dim today button when on current month and hide tooltip on click

* fix(dash): use Theme.shortDuration for calendar today button opacity animation

* refactor(dash): use standard DankActionButton tooltipText for calendar today button

* chore(dash): remove unused todayButton id

* fix(dash): allow returning to today when another day in current month is selected

(cherry picked from commit 07d8e28668)
2026-09-04 10:38:44 -04:00
bbedward a478c27fd0 bar: make the global shadow toggles authoritative
(cherry picked from commit 4e2afb53cc)
2026-09-04 10:38:37 -04:00
bbedward 094dcdd035 matugen: stop embedding the adw-gtk3 body in the gtk light template
fixes #3282
port 1.6

(cherry picked from commit eb53d8983e)
2026-09-04 14:38:01 +00:00
bbedward 344c1e610c theme: write desktop settings through dconf instead of gsettings
fixes #3280
port 1.6

(cherry picked from commit a665ba8ca5)
2026-09-04 14:37:58 +00:00
bbedward fdf8722818 matugen: give dry runs their own config file
fixes #3279
port 1.6

(cherry picked from commit d57e32c5cb)
2026-09-04 14:37:56 +00:00
usoaraway0 f2114c1a01 brightness: retry initial ddc read (#3287)
port 1.6

(cherry picked from commit ec22e9e594)
2026-09-04 13:51:54 +00:00
枫莹 6ed79d06df fix(plugins): use plugin ids for management actions (#3284)
(cherry picked from commit 433b13cb0e)
2026-09-04 09:51:29 -04:00
usoaraway0 8ff677d2eb niri: create screenshot directory on demand (#3286)
port 1.6

(cherry picked from commit cbe572eea6)
2026-09-04 13:46:32 +00:00
purian23 700f43a194 feat(island): allow hiding clock or date options in compact pill
Port 1.6

(cherry picked from commit c1f1da1d26)
2026-09-04 04:47:35 +00:00
bbedward 53a5582858 build: remove install-shell from install in Makefile
(cherry picked from commit 2008c39af3)
2026-09-03 18:28:38 -04:00
73 changed files with 39931 additions and 6324 deletions
+1 -1
View File
@@ -531,7 +531,7 @@ jobs:
repo:
- dgop
- dankgo
- dank-qml-common
# - dank-qml-common
- dankcalendar
- danksearch
- dank-greeter
+1 -1
View File
@@ -58,7 +58,7 @@ func init() {
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip AUR updates")
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
+1 -1
View File
@@ -80,7 +80,7 @@ require (
)
require (
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
+2 -2
View File
@@ -1,7 +1,7 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e h1:AUCBM5EF+hxCG55uJl30waRQHhR5+Wij9u8AfoTkQvs=
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e/go.mod h1:w1W/vDrWJCLdjVI4E/mURiKUQLMCmiGiPLxZEeP5kp8=
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea h1:A2IuXIzhTQwvBZiS8pxU5RmWSszMd16Oh11gUlJ9OeU=
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea/go.mod h1:w1W/vDrWJCLdjVI4E/mURiKUQLMCmiGiPLxZEeP5kp8=
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577 h1:z7daEy56wLH6R7PGS3MKIVoDHNdVoDptY/f2i61EDCQ=
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577/go.mod h1:2kt1u9YgHwi2npqB5ago8kwZdFrHlPGXv3PM/vwRHfY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+51 -26
View File
@@ -661,9 +661,12 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
if existingData != "" {
existingConfig = existingData
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath))
result.BackupPath = filepath.Join(backupDir, filepath.Base(existingPath))
if err := backupHyprlandConfigFile(existingPath, result.BackupPath, []byte(existingData), strings.EqualFold(filepath.Ext(existingPath), ".conf")); err != nil {
}
// A legacy hyprland.conf is only backed up once hyprland.lua is in place; Hyprland
// watches the live config and regenerates a stub one if it disappears first.
if existingData != "" && !strings.EqualFold(filepath.Ext(existingPath), ".conf") {
result.BackupPath, err = backupHyprlandConfigFile(filepath.Join(backupDir, filepath.Base(existingPath)), []byte(existingData))
if err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
@@ -711,11 +714,15 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
}
}
movedLegacy, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
movedLegacy, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
if err != nil {
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
return result, result.Error
}
if mainConfBackup != "" && result.BackupPath == "" {
result.BackupPath = mainConfBackup
cd.log(fmt.Sprintf("Backed up existing config to %s", mainConfBackup))
}
if movedLegacy > 0 {
if result.BackupPath == "" {
result.BackupPath = backupDir
@@ -737,42 +744,53 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, nil
}
func backupHyprlandConfigFile(src, dst string, data []byte, removeSource bool) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
func uniqueBackupPath(dst string) string {
if _, err := os.Lstat(dst); err != nil {
return dst
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return err
}
if removeSource {
if err := os.Remove(src); err != nil && !os.IsNotExist(err) {
return err
for i := 1; i < 1000; i++ {
candidate := fmt.Sprintf("%s.%d", dst, i)
if _, err := os.Lstat(candidate); err != nil {
return candidate
}
}
return nil
return fmt.Sprintf("%s.%d", dst, time.Now().UnixNano())
}
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, error) {
legacyPaths := []string{filepath.Join(configDir, "hyprland.conf")}
func backupHyprlandConfigFile(dst string, data []byte) (string, error) {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return "", err
}
dst = uniqueBackupPath(dst)
if err := os.WriteFile(dst, data, 0o644); err != nil {
return "", err
}
return dst, nil
}
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, string, error) {
mainConf := filepath.Join(configDir, "hyprland.conf")
legacyPaths := []string{mainConf}
dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf"))
if err != nil {
return 0, err
return 0, "", err
}
legacyPaths = append(legacyPaths, dmsConfPaths...)
backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir)
if err != nil {
return 0, err
return 0, "", err
}
legacyPaths = append(legacyPaths, backupPaths...)
moved := 0
mainConfBackup := ""
for _, src := range legacyPaths {
info, err := os.Lstat(src)
if os.IsNotExist(err) {
continue
}
if err != nil {
return moved, err
return moved, mainConfBackup, err
}
if info.IsDir() {
continue
@@ -782,21 +800,28 @@ func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, er
if err != nil {
rel = filepath.Base(src)
}
dst := filepath.Join(backupDir, rel)
if err := moveHyprlandConfigFile(src, dst); err != nil {
return moved, err
dst, err := moveHyprlandConfigFile(src, filepath.Join(backupDir, rel))
if err != nil {
return moved, mainConfBackup, err
}
if src == mainConf {
mainConfBackup = dst
}
moved++
}
return moved, nil
return moved, mainConfBackup, nil
}
func moveHyprlandConfigFile(src, dst string) error {
func moveHyprlandConfigFile(src, dst string) (string, error) {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
return "", err
}
return os.Rename(src, dst)
dst = uniqueBackupPath(dst)
if err := os.Rename(src, dst); err != nil {
return "", err
}
return dst, nil
}
func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) {
+69
View File
@@ -890,3 +890,72 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
}
})
}
func TestBackupLegacyHyprlandConfFilesKeepsExistingBackups(t *testing.T) {
td := t.TempDir()
configDir := filepath.Join(td, ".config", "hypr")
dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
userConfig := "# the config the user actually wrote\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
backupDir := filepath.Join(configDir, hyprlandBackupDirName, "2026-01-02_03-04-05")
require.NoError(t, os.MkdirAll(backupDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(backupDir, "hyprland.conf"), []byte(userConfig), 0o644))
autogenerated := "autogenerated = 1\n$mainMod = SUPER\n"
require.NoError(t, os.WriteFile(filepath.Join(configDir, "hyprland.conf"), []byte(autogenerated), 0o644))
moved, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
require.NoError(t, err)
assert.Equal(t, 1, moved)
assert.Equal(t, filepath.Join(backupDir, "hyprland.conf.1"), mainConfBackup)
kept, err := os.ReadFile(filepath.Join(backupDir, "hyprland.conf"))
require.NoError(t, err)
assert.Equal(t, userConfig, string(kept))
stray, err := os.ReadFile(mainConfBackup)
require.NoError(t, err)
assert.Equal(t, autogenerated, string(stray))
}
func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) {
backupDir := t.TempDir()
dst := filepath.Join(backupDir, "hyprland.lua")
require.NoError(t, os.WriteFile(dst, []byte("-- first\n"), 0o644))
written, err := backupHyprlandConfigFile(dst, []byte("-- second\n"))
require.NoError(t, err)
assert.Equal(t, dst+".1", written)
first, err := os.ReadFile(dst)
require.NoError(t, err)
assert.Equal(t, "-- first\n", string(first))
}
func TestDeployHyprlandConfigWritesLuaBeforeRemovingLegacyConf(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755))
confPath := filepath.Join(configDir, "hyprland.conf")
userConfig := "# user config\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
require.NoError(t, os.WriteFile(confPath, []byte(userConfig), 0o644))
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
go func() {
for range logChan {
}
}()
defer close(logChan)
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
require.NoError(t, err)
assert.NoFileExists(t, confPath)
backup, err := os.ReadFile(result.BackupPath)
require.NoError(t, err)
assert.Equal(t, userConfig, string(backup))
assert.Equal(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"), result.BackupPath)
}
+2 -2
View File
@@ -179,8 +179,8 @@ func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if err != nil {
rel = filepath.Base(src)
}
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
if err := moveHyprlandConfigFile(src, dst); err != nil {
dst, err := moveHyprlandConfigFile(src, filepath.Join(configDir, hyprlandBackupDirName, ts, rel))
if err != nil {
if logFn != nil {
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
}
+15 -4
View File
@@ -85,9 +85,10 @@ type OutputSurface struct {
slots [3]*RenderSlot
slotsReady bool
shown *overlay
framePending bool
redrawQueued bool
shown *overlay
framePending bool
redrawQueued bool
committedCursor bool
}
type PreCapture struct {
@@ -108,6 +109,7 @@ type RegionSelector struct {
shm *client.Shm
seat *client.Seat
pointer *client.Pointer
touch *client.Touch
keyboard *client.Keyboard
keymap *keymap.Keymap
layerShell *wlr_layer_shell.ZwlrLayerShellV1
@@ -138,6 +140,8 @@ type RegionSelector struct {
selection SelectionState
pointerX float64
pointerY float64
hasTouchPoint bool
touchPointId int32
preSelect Region
showCapturedCursor bool
shiftHeld bool
@@ -985,7 +989,10 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
case r.compositorVersion >= 4:
fullDamage = false
damage = overlayDamage(os.shown, cur)
if len(damage) == 0 {
if os.committedCursor != r.showCapturedCursor {
fullDamage = true
damage = nil
} else if len(damage) == 0 {
return
}
}
@@ -1029,6 +1036,7 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
})
}
_ = os.wlSurface.Commit()
os.committedCursor = r.showCapturedCursor
// Mark this slot as busy until compositor releases it
slot.busy = true
@@ -1103,6 +1111,9 @@ func (r *RegionSelector) cleanup() {
if r.pointer != nil {
r.pointer.Release()
}
if r.touch != nil {
_ = r.touch.Release()
}
if r.keyboard != nil {
r.keyboard.Release()
}
+143
View File
@@ -19,6 +19,12 @@ func (r *RegionSelector) setupInput() {
r.setupPointerHandlers()
}
}
if e.Capabilities&uint32(client.SeatCapabilityTouch) != 0 && r.touch == nil {
if touch, err := r.seat.GetTouch(); err == nil {
r.touch = touch
r.setupTouchHandlers()
}
}
if e.Capabilities&uint32(client.SeatCapabilityKeyboard) != 0 && r.keyboard == nil {
if keyboard, err := r.seat.GetKeyboard(); err == nil {
r.keyboard = keyboard
@@ -146,6 +152,143 @@ func (r *RegionSelector) setupPointerHandlers() {
})
}
func (r *RegionSelector) setupTouchHandlers() {
r.touch.SetDownHandler(func(e client.TouchDownEvent) {
r.handleTouchDown(e.Surface, e.Id, e.X, e.Y)
})
r.touch.SetMotionHandler(func(e client.TouchMotionEvent) {
r.handleTouchMotion(e.Id, e.X, e.Y)
})
r.touch.SetUpHandler(func(e client.TouchUpEvent) {
r.handleTouchUp(e.Id)
})
r.touch.SetCancelHandler(func(e client.TouchCancelEvent) {
r.handleTouchCancel()
})
}
func (r *RegionSelector) handleTouchDown(surface *client.Surface, touchId int32, x, y float64) {
if r.hasTouchPoint {
return
}
r.activeSurface = nil
for _, os := range r.surfaces {
if os.wlSurface != nil && surface != nil && os.wlSurface.ID() == surface.ID() {
r.activeSurface = os
break
}
}
if r.activeSurface == nil {
return
}
r.hasTouchPoint = true
r.touchPointId = touchId
if r.phase == phaseScroll {
if r.activeSurface != r.selection.surface {
return
}
switch r.scrollBarHit(x, y) {
case "done", "preview":
r.finishScroll()
case "cancel":
r.cancelled = true
r.running = false
}
return
}
touchX := x + float64(r.activeSurface.output.x)
touchY := y + float64(r.activeSurface.output.y)
if r.ctrlHeld && r.selection.hasSelection {
if handle := r.resizeHandleAt(touchX, touchY); handle != handleNone {
if r.beginSelectionResize(handle, touchX, touchY) {
return
}
}
if r.beginSelectionMove(touchX, touchY) {
r.selection.dragging = true
return
}
}
r.preSelect = Region{}
r.movingSelection = false
r.resizingHandle = handleNone
r.selection.hasSelection = true
r.selection.dragging = true
r.selection.surface = r.activeSurface
r.selection.anchorX = touchX
r.selection.anchorY = touchY
r.selection.currentX = r.selection.anchorX
r.selection.currentY = r.selection.anchorY
for _, os := range r.surfaces {
r.redrawSurface(os)
}
}
func (r *RegionSelector) handleTouchMotion(touchId int32, x, y float64) {
if !r.hasTouchPoint || touchId != r.touchPointId || r.activeSurface == nil {
return
}
if r.phase == phaseScroll {
return
}
if !r.selection.dragging {
return
}
r.updateSelectionCurrent(r.activeSurface, x, y)
}
func (r *RegionSelector) handleTouchUp(touchId int32) {
if !r.hasTouchPoint || touchId != r.touchPointId {
return
}
r.hasTouchPoint = false
r.selection.dragging = false
r.movingSelection = false
r.resizingHandle = handleNone
if r.phase == phaseScroll {
return
}
for _, os := range r.surfaces {
r.redrawSurface(os)
}
if r.screenshoter != nil && r.screenshoter.config.NoConfirm && r.selection.hasSelection {
r.finishSelection()
}
}
func (r *RegionSelector) handleTouchCancel() {
if !r.hasTouchPoint {
return
}
r.hasTouchPoint = false
r.selection.dragging = false
r.movingSelection = false
r.resizingHandle = handleNone
for _, os := range r.surfaces {
r.redrawSurface(os)
}
}
func (r *RegionSelector) updateSelectionCurrent(os *OutputSurface, surfaceX, surfaceY float64) {
if os == nil || os.output == nil || !r.selection.dragging {
return
@@ -0,0 +1,152 @@
package screenshot
import (
"testing"
"github.com/AvengeMedia/dankgo/wayland/client"
)
func newTestSurface(id uint32) *client.Surface {
s := &client.Surface{}
s.SetID(id)
return s
}
func TestTouchInputSequence(t *testing.T) {
surface := newTestSurface(100)
os := &OutputSurface{
output: &WaylandOutput{
x: 0,
y: 0,
},
logicalW: 1920,
logicalH: 1080,
wlSurface: surface,
}
r := &RegionSelector{
running: true,
surfaces: []*OutputSurface{os},
}
// 1. Touch Down
r.handleTouchDown(surface, 1, 100, 200)
if !r.hasTouchPoint || r.touchPointId != 1 {
t.Fatalf("expected hasTouchPoint=true, touchPointId=1, got hasTouchPoint=%v, id=%d", r.hasTouchPoint, r.touchPointId)
}
if !r.selection.dragging || !r.selection.hasSelection {
t.Fatalf("expected dragging=true, hasSelection=true")
}
if r.selection.anchorX != 100 || r.selection.anchorY != 200 {
t.Fatalf("expected anchor (100, 200), got (%.1f, %.1f)", r.selection.anchorX, r.selection.anchorY)
}
// 2. Ignore secondary touch point while primary is active
surface2 := newTestSurface(200)
r.handleTouchDown(surface2, 2, 500, 600)
if r.touchPointId != 1 {
t.Fatalf("expected touchPointId to stay 1, got %d", r.touchPointId)
}
// 3. Touch Motion with matching id
r.handleTouchMotion(1, 400, 500)
if r.selection.currentX != 400 || r.selection.currentY != 500 {
t.Fatalf("expected current (400, 500), got (%.1f, %.1f)", r.selection.currentX, r.selection.currentY)
}
// 4. Touch Motion with mismatched id should be ignored
r.handleTouchMotion(2, 900, 900)
if r.selection.currentX != 400 || r.selection.currentY != 500 {
t.Fatalf("expected current to remain (400, 500), got (%.1f, %.1f)", r.selection.currentX, r.selection.currentY)
}
// 5. Touch Up with mismatched id should not release
r.handleTouchUp(2)
if !r.hasTouchPoint || !r.selection.dragging {
t.Fatalf("expected touch point to remain active on mismatched touch up")
}
// 6. Touch Up with matching id
r.handleTouchUp(1)
if r.hasTouchPoint || r.selection.dragging {
t.Fatalf("expected hasTouchPoint=false, dragging=false after touch up")
}
if !r.selection.hasSelection {
t.Fatalf("expected selection to be preserved after touch up")
}
}
func TestTouchCancel(t *testing.T) {
surface := newTestSurface(100)
os := &OutputSurface{
output: &WaylandOutput{
x: 0,
y: 0,
},
logicalW: 1920,
logicalH: 1080,
wlSurface: surface,
}
r := &RegionSelector{
running: true,
surfaces: []*OutputSurface{os},
}
r.handleTouchDown(surface, 5, 50, 60)
if !r.hasTouchPoint || !r.selection.dragging {
t.Fatalf("expected active touch point")
}
r.handleTouchCancel()
if r.hasTouchPoint || r.selection.dragging {
t.Fatalf("expected touch point and dragging to be cleared on cancel")
}
}
func TestTouchUpScrollPhase(t *testing.T) {
surface := newTestSurface(100)
os := &OutputSurface{
output: &WaylandOutput{
x: 0,
y: 0,
},
logicalW: 1920,
logicalH: 1080,
wlSurface: surface,
}
sc := &Screenshoter{
config: Config{NoConfirm: true},
}
r := &RegionSelector{
screenshoter: sc,
phase: phaseScroll,
running: true,
surfaces: []*OutputSurface{os},
selection: SelectionState{
hasSelection: true,
surface: os,
},
}
r.hasTouchPoint = true
r.touchPointId = 3
r.selection.dragging = true
r.handleTouchUp(3)
if r.hasTouchPoint || r.selection.dragging {
t.Fatalf("expected touch point and dragging cleared")
}
// In scroll phase with NoConfirm, handleTouchUp must not call finishSelection / change running state
if !r.running {
t.Fatalf("expected running to stay true, did not expect finishSelection side-effects")
}
}
@@ -17,6 +17,7 @@ import (
func init() {
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
RegisterSystemBackend(func() Backend { return &shellyBackend{} })
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
}
@@ -53,9 +54,7 @@ func pacmanUpgradeArgv(opts UpgradeOptions) []string {
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
}
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
// AUR packages are exempt — holding those cannot break the repo dependency graph.
// Arch repository packages must upgrade together; AUR packages may be held.
func dropPacmanRepoIgnores(ignored []string, pending []Package) []string {
if len(ignored) == 0 {
return ignored
@@ -81,7 +80,7 @@ func isPacmanFamily(b Backend) bool {
return false
}
switch b.ID() {
case "pacman", "paru", "yay":
case "pacman", "paru", "yay", "shelly":
return true
default:
return false
@@ -0,0 +1,168 @@
package sysupdate
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"slices"
"strings"
"time"
)
type shellyBackend struct{}
func (shellyBackend) ID() string { return "shelly" }
func (shellyBackend) DisplayName() string { return "Shelly (AUR)" }
func (shellyBackend) Repo() RepoKind { return RepoSystem }
func (shellyBackend) NeedsAuth() bool { return true }
func (shellyBackend) RunsInTerminal() bool {
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
}
func (shellyBackend) IsAvailable(ctx context.Context) bool {
if !commandExists("shelly") {
return false
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
out, err := Capture(ctx, []string{"shelly", "--version", "--json"})
if err != nil {
return false
}
var version struct {
Name string `json:"name"`
SchemaVersion int `json:"schemaVersion"`
}
// Shelly 3.1.3 introduced this CLI identity response.
return json.Unmarshal([]byte(out), &version) == nil && version.Name == "shelly" && version.SchemaVersion == 1
}
func (b shellyBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
pkgs, err := b.checkUpdates(ctx, RepoSystem)
if err != nil {
return nil, err
}
aur, err := b.checkUpdates(ctx, RepoAUR)
if err != nil {
return nil, err
}
return append(pkgs, aur...), nil
}
func (b shellyBackend) checkUpdates(ctx context.Context, repo RepoKind) ([]Package, error) {
kind := "standard"
if repo == RepoAUR {
kind = "aur"
}
out, err := Capture(ctx, []string{"shelly", "list-updates", kind, "--json"})
if ctx.Err() != nil {
return nil, ctx.Err()
}
if err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
return nil, fmt.Errorf("shelly %s update check: %w: %s", kind, err, strings.TrimSpace(string(exitErr.Stderr)))
}
return nil, fmt.Errorf("shelly %s update check: %w", kind, err)
}
return parseShellyUpdates(out, repo)
}
func parseShellyUpdates(out string, repo RepoKind) ([]Package, error) {
var updates []struct {
Name string
CurrentVersion string
Version string
NewVersion string
DownloadSize int64
PackageBase string
}
if err := json.Unmarshal([]byte(out), &updates); err != nil {
return nil, fmt.Errorf("shelly %s updates: invalid JSON: %w", repo, err)
}
if updates == nil {
return nil, fmt.Errorf("shelly %s updates: expected a JSON array", repo)
}
var pkgs []Package
for _, update := range updates {
fromVersion := update.CurrentVersion
if repo == RepoAUR {
fromVersion = update.Version
}
if update.Name == "" || fromVersion == "" || update.NewVersion == "" {
return nil, fmt.Errorf("shelly %s updates: missing package name or version", repo)
}
pkg := Package{
Name: update.Name,
Backend: "shelly",
Repo: repo,
FromVersion: fromVersion,
ToVersion: update.NewVersion,
SizeBytes: max(0, update.DownloadSize),
}
if repo == RepoAUR {
base := update.PackageBase
if base == "" {
base = update.Name
}
pkg.ChangelogURL = "https://aur.archlinux.org/packages/" + url.PathEscape(base)
}
pkgs = append(pkgs, pkg)
}
return pkgs, nil
}
func (b shellyBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
var aur []Package
if opts.IncludeAUR && (opts.DryRun || len(opts.Ignored) > 0) {
// The manager has already removed held packages from opts.Targets.
var err error
aur, err = b.checkUpdates(ctx, RepoAUR)
if err != nil {
return err
}
for _, pkg := range aur {
if slices.Contains(opts.Ignored, pkg.Name) {
return fmt.Errorf("shelly cannot exclude held AUR package %q; disable AUR updates or remove its DMS hold before updating", pkg.Name)
}
}
}
if opts.DryRun {
pkgs, err := b.checkUpdates(ctx, RepoSystem)
if err != nil {
return err
}
pkgs = append(pkgs, aur...)
for _, pkg := range pkgs {
if onLine != nil {
onLine(fmt.Sprintf("%s %s -> %s", pkg.Name, pkg.FromVersion, pkg.ToVersion))
}
}
return nil
}
argv := shellyUpgradeArgv(opts.IncludeAUR)
if opts.AttachStdio {
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: true})
}
if !b.RunsInTerminal() {
return Run(ctx, argv, RunOptions{OnLine: onLine, Env: []string{"SHELLY_ELEVATOR=pkexec"}})
}
term := findTerminal(opts.Terminal)
if term == "" {
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
}
return Run(ctx, wrapInTerminal(term, "DMS — System Update (shelly)", strings.Join(argv, " "), opts.TerminalArgs), RunOptions{OnLine: onLine})
}
func shellyUpgradeArgv(includeAUR bool) []string {
if !includeAUR {
return []string{"shelly", "upgrade", "standard", "--no-confirm"}
}
return []string{"shelly", "upgrade", "all", "--no-flatpak", "--no-appimage", "--no-confirm"}
}
@@ -0,0 +1,298 @@
package sysupdate
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
const shellyRepoFixture = `[{"Name":"linux","CurrentVersion":"2:6.18-1","NewVersion":"2:6.19-1","DownloadSize":1024,"Repository":"core"}]`
const shellyAURFixture = `[{"Name":"example-git","Version":"r1-1","NewVersion":"r2-1","DownloadSize":-1,"PackageBase":"example"}]`
func TestParseShellyUpdates(t *testing.T) {
tests := []struct {
name string
out string
repo RepoKind
want []Package
}{
{"repository", shellyRepoFixture, RepoSystem, []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem, FromVersion: "2:6.18-1", ToVersion: "2:6.19-1", SizeBytes: 1024}}},
{"aur package base", shellyAURFixture, RepoAUR, []Package{{Name: "example-git", Backend: "shelly", Repo: RepoAUR, FromVersion: "r1-1", ToVersion: "r2-1", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
{"aur name fallback", `[{"Name":"example","Version":"1","NewVersion":"2"}]`, RepoAUR, []Package{{Name: "example", Backend: "shelly", Repo: RepoAUR, FromVersion: "1", ToVersion: "2", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
{"empty", "[]\n", RepoSystem, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseShellyUpdates(tt.out, tt.repo)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("packages = %#v, want %#v", got, tt.want)
}
})
}
for _, out := range []string{"", "null", "{}", "[", "[] trailing", "[null]", `[{"Name":"linux"}]`, `[{"Name":"linux","CurrentVersion":"1","NewVersion":"2","DownloadSize":"large"}]`} {
t.Run(out, func(t *testing.T) {
if _, err := parseShellyUpdates(out, RepoSystem); err == nil {
t.Fatalf("expected error for %q", out)
}
})
}
}
func writeUpdateExecutable(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
}
func TestShellySelection(t *testing.T) {
for _, tt := range []struct {
name string
bins []string
version string
want string
}{
{"shelly only", []string{"shelly"}, `{"schemaVersion":1,"name":"shelly","version":"3.1.3"}`, "shelly"},
{"shelly before pacman", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly"}`, "shelly"},
{"paru preferred", []string{"paru", "yay", "shelly", "pacman"}, "", "paru"},
{"yay preferred", []string{"yay", "shelly", "pacman"}, "", "yay"},
{"legacy shelly", []string{"shelly", "pacman"}, "2.4.1", "pacman"},
{"wrong binary", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly-ui"}`, "pacman"},
{"unknown schema", []string{"shelly", "pacman"}, `{"schemaVersion":2,"name":"shelly"}`, "pacman"},
{"pacman only", []string{"pacman"}, "", "pacman"},
} {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
for _, bin := range tt.bins {
writeUpdateExecutable(t, dir, bin, "printf '%s\\n' '"+tt.version+"'")
}
selection := Select(t.Context())
if selection.System == nil || selection.System.ID() != tt.want {
t.Fatalf("selection = %#v, want %s", selection.System, tt.want)
}
})
}
}
func fakeShelly(t *testing.T) (string, string) {
t.Helper()
dir := t.TempDir()
log := filepath.Join(dir, "calls")
t.Setenv("PATH", dir)
t.Setenv("TERMINAL", "")
t.Setenv("DMS_FORCE_PKEXEC", "")
t.Setenv("DMS_TEST_SHELLY_LOG", log)
t.Setenv("DMS_TEST_SHELLY_EXIT", "0")
t.Setenv("SHELLY_ELEVATOR", "")
writeUpdateExecutable(t, dir, "shelly", `printf '%s|%s\n' "$*" "$SHELLY_ELEVATOR" >> "$DMS_TEST_SHELLY_LOG"
case "$*" in
'list-updates standard --json') printf '%s\n' '`+shellyRepoFixture+`' ;;
'list-updates aur --json') printf '%s\n' '`+shellyAURFixture+`' ;;
'upgrade standard --no-confirm'|'upgrade all --no-flatpak --no-appimage --no-confirm') exit "$DMS_TEST_SHELLY_EXIT" ;;
*) echo "unexpected arguments: $*" >&2; exit 99 ;;
esac`)
return dir, log
}
func readUpdateCalls(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatal(err)
}
return string(data)
}
func TestShellyCheckUpdates(t *testing.T) {
_, log := fakeShelly(t)
pkgs, err := (shellyBackend{}).CheckUpdates(t.Context())
if err != nil {
t.Fatal(err)
}
if len(pkgs) != 2 || pkgs[0].Repo != RepoSystem || pkgs[1].Repo != RepoAUR {
t.Fatalf("unexpected packages: %#v", pkgs)
}
if got, want := readUpdateCalls(t, log), "list-updates standard --json|\nlist-updates aur --json|\n"; got != want {
t.Fatalf("calls = %q, want %q", got, want)
}
}
func TestShellyCheckFailures(t *testing.T) {
for _, code := range []string{"1", "2"} {
t.Run(code, func(t *testing.T) {
dir, _ := fakeShelly(t)
writeUpdateExecutable(t, dir, "shelly", "echo 'database unavailable' >&2; exit "+code)
if _, err := (shellyBackend{}).CheckUpdates(t.Context()); err == nil || !strings.Contains(err.Error(), "database unavailable") {
t.Fatalf("expected query failure with stderr, got %v", err)
}
})
}
t.Run("cancellation", func(t *testing.T) {
dir, _ := fakeShelly(t)
writeUpdateExecutable(t, dir, "shelly", "exec /bin/sleep 30")
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
if _, err := (shellyBackend{}).CheckUpdates(ctx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected deadline error, got %v", err)
}
})
}
func TestShellyUpgrade(t *testing.T) {
for _, tt := range []struct {
name string
includeAUR bool
attached bool
terminal bool
}{
{"pkexec repository", false, false, false},
{"pkexec aur", true, false, false},
{"attached repository", false, true, false},
{"attached aur", true, true, false},
{"terminal repository", false, false, true},
{"terminal aur", true, false, true},
} {
t.Run(tt.name, func(t *testing.T) {
dir, log := fakeShelly(t)
opts := UpgradeOptions{IncludeAUR: tt.includeAUR, AttachStdio: tt.attached, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
elevator := ""
if tt.terminal {
opts.Terminal = "fake-terminal"
writeUpdateExecutable(t, dir, opts.Terminal, `while [ "$1" != sh ] && [ "$#" -gt 0 ]; do shift; done
shift
exec /bin/sh "$@"`)
} else if !tt.attached {
t.Setenv("DMS_FORCE_PKEXEC", "1")
elevator = "pkexec"
}
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err != nil {
t.Fatal(err)
}
command := "upgrade standard --no-confirm"
if tt.includeAUR {
command = "upgrade all --no-flatpak --no-appimage --no-confirm"
}
if got, want := readUpdateCalls(t, log), command+"|"+elevator+"\n"; got != want {
t.Fatalf("calls = %q, want %q", got, want)
}
})
}
}
func TestShellyUpgradeHolds(t *testing.T) {
for _, tt := range []struct {
name string
includeAUR bool
ignored []string
wantError bool
dryRun bool
}{
{"held aur", true, []string{"example-git"}, true, false},
{"repository only", false, []string{"example-git"}, false, false},
{"repository hold cannot exclude system upgrade", true, []string{"linux"}, false, false},
{"unrelated hold", true, []string{"org.example.Flatpak"}, false, false},
{"dry held aur", true, []string{"example-git"}, true, true},
{"dry repository only", false, []string{"example-git"}, false, true},
{"dry repository hold", true, []string{"linux"}, false, true},
{"dry unrelated hold", true, []string{"org.example.Flatpak"}, false, true},
} {
t.Run(tt.name, func(t *testing.T) {
_, log := fakeShelly(t)
t.Setenv("DMS_FORCE_PKEXEC", "1")
opts := UpgradeOptions{DryRun: tt.dryRun, IncludeAUR: tt.includeAUR, Ignored: tt.ignored, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
calls := readUpdateCalls(t, log)
if strings.Count(calls, "list-updates aur --json") > 1 {
t.Fatalf("duplicate AUR check: %q", calls)
}
if tt.wantError {
if err == nil || err.Error() != `shelly cannot exclude held AUR package "example-git"; disable AUR updates or remove its DMS hold before updating` || strings.Contains(calls, "upgrade ") {
t.Fatalf("held AUR package must block upgrade, got %v, calls %q", err, readUpdateCalls(t, log))
}
return
}
if err != nil || strings.Contains(calls, "upgrade ") == tt.dryRun {
t.Fatalf("unexpected result for dryRun=%v: %v, calls %q", tt.dryRun, err, calls)
}
})
}
if !isPacmanFamily(shellyBackend{}) {
t.Fatal("Shelly must enforce repository hold restrictions")
}
}
func TestShellyHoldCheckFailure(t *testing.T) {
for _, dryRun := range []bool{false, true} {
dir, log := fakeShelly(t)
writeUpdateExecutable(t, dir, "shelly", `printf '%s\n' "$*" >> "$DMS_TEST_SHELLY_LOG"
echo 'AUR unavailable' >&2
exit 1`)
opts := UpgradeOptions{DryRun: dryRun, IncludeAUR: true, Ignored: []string{"example-git"}, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
if err == nil || !strings.Contains(err.Error(), "AUR unavailable") {
t.Fatalf("dryRun=%v: expected AUR query error, got %v", dryRun, err)
}
if calls := readUpdateCalls(t, log); calls != "list-updates aur --json\n" {
t.Fatalf("query failure must stop the operation: %q", calls)
}
}
}
func TestShellyDryRun(t *testing.T) {
for _, includeAUR := range []bool{false, true} {
_, log := fakeShelly(t)
var lines []string
opts := UpgradeOptions{DryRun: true, IncludeAUR: includeAUR, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
if err := (shellyBackend{}).Upgrade(t.Context(), opts, func(line string) { lines = append(lines, line) }); err != nil {
t.Fatal(err)
}
wantCalls := "list-updates standard --json|\n"
wantLines := []string{"linux 2:6.18-1 -> 2:6.19-1"}
if includeAUR {
wantCalls = "list-updates aur --json|\n" + wantCalls
wantLines = append(wantLines, "example-git r1-1 -> r2-1")
}
if got := readUpdateCalls(t, log); got != wantCalls || !reflect.DeepEqual(lines, wantLines) {
t.Fatalf("calls = %q, lines = %q", got, lines)
}
}
}
func TestShellySkipsExcludedTargets(t *testing.T) {
for _, targets := range [][]Package{
nil,
{{Name: "example-git", Backend: "shelly", Repo: RepoAUR}},
{{Name: "org.example.App", Backend: "flatpak", Repo: RepoFlatpak}},
{{Name: "linux", Backend: "pacman", Repo: RepoSystem}},
} {
_, log := fakeShelly(t)
if err := (shellyBackend{}).Upgrade(t.Context(), UpgradeOptions{Targets: targets, IncludeFlatpak: true}, nil); err != nil {
t.Fatal(err)
}
if got := readUpdateCalls(t, log); got != "" {
t.Fatalf("excluded targets executed commands: %q", got)
}
}
}
func TestShellyUpgradeFailure(t *testing.T) {
_, _ = fakeShelly(t)
t.Setenv("DMS_FORCE_PKEXEC", "1")
t.Setenv("DMS_TEST_SHELLY_EXIT", "126")
opts := UpgradeOptions{Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err == nil {
t.Fatal("authentication or upgrade failure must propagate")
}
}
+9 -1
View File
@@ -35,6 +35,7 @@ func Run(ctx context.Context, argv []string, opts RunOptions) error {
}
return cmd.Process.Kill()
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
@@ -125,7 +126,14 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
title, shellCmd,
)
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
closer := `dms_update_status=$?
if [ "$dms_update_status" -eq 0 ]; then
printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'
else
printf '\n\033[1;31m=== Update failed (exit %s). Press Enter to close. ===\033[0m\n' "$dms_update_status"
fi
read -r dms_update_reply
exit "$dms_update_status"`
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
full := export + banner + "; " + shellCmd + "; " + closer
@@ -0,0 +1,58 @@
package sysupdate
import (
"errors"
"os"
"os/exec"
"strings"
"testing"
)
func TestTerminalWrapperPreservesExitStatus(t *testing.T) {
for _, tt := range []struct {
command string
status int
message string
}{
{"true", 0, "Done."},
{"false", 1, "Update failed (exit 1)"},
{"sh -c 'exit 42'", 42, "Update failed (exit 42)"},
} {
t.Run(tt.command, func(t *testing.T) {
argv := wrapInTerminal("xterm", "Test", tt.command, nil)
cmd := exec.Command("sh", "-c", argv[len(argv)-1])
cmd.Stdin = strings.NewReader("\n")
out, err := cmd.CombinedOutput()
if tt.status == 0 && err != nil {
t.Fatal(err)
}
if tt.status != 0 {
exitErr, ok := errors.AsType[*exec.ExitError](err)
if !ok || exitErr.ExitCode() != tt.status {
t.Fatalf("expected exit %d, got %v (%s)", tt.status, err, out)
}
}
if !strings.Contains(string(out), tt.message) {
t.Fatalf("missing status message %q: %s", tt.message, out)
}
})
}
}
func TestRunAttachesStdin(t *testing.T) {
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer reader.Close()
if _, err := writer.WriteString("confirmation\n"); err != nil {
t.Fatal(err)
}
writer.Close()
previous := os.Stdin
os.Stdin = reader
defer func() { os.Stdin = previous }()
if err := Run(t.Context(), []string{"sh", "-c", `read -r answer && [ "$answer" = confirmation ]`}, RunOptions{AttachStdio: true}); err != nil {
t.Fatalf("attached command did not receive stdin: %v", err)
}
}
+2 -2
View File
@@ -420,8 +420,8 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
m.failCustomUpgrade(ErrCodeCancelled, err)
return
}
// exit status reflects the trailing `read`, not the update command
m.appendLog(fmt.Sprintf("Terminal exited early: %v", err))
m.failCustomUpgrade(ErrCodeBackendFailed, err)
return
}
m.finishSuccessfulUpgrade(false)
@@ -0,0 +1,52 @@
package sysupdate
import (
"context"
"slices"
"strings"
"testing"
)
type customUpgradeTestBackend struct {
pacmanBackend
checks int
}
func (b *customUpgradeTestBackend) CheckUpdates(context.Context) ([]Package, error) {
b.checks++
return nil, nil
}
func TestCustomUpgradeFailure(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
writeUpdateExecutable(t, dir, "fake-terminal", `while [ "$#" -gt 0 ] && [ "$1" != sh ]; do shift; done
[ "$#" -gt 0 ] || exit 99
shift
exec /bin/sh "$@"`)
backend := &customUpgradeTestBackend{}
m := &Manager{
selection: Selection{System: backend},
notifyDirty: make(chan struct{}, 1),
stopChan: make(chan struct{}),
}
m.runCustomUpgrade(t.Context(), UpgradeOptions{
Terminal: "fake-terminal",
CustomCommand: "false",
})
state := m.GetState()
if state.Phase != PhaseError {
t.Errorf("phase = %s, want %s", state.Phase, PhaseError)
}
if state.Error == nil || state.Error.Code != ErrCodeBackendFailed || !strings.Contains(state.Error.Message, "exit status 1") {
t.Errorf("error = %#v, want backend-failed with exit status 1", state.Error)
}
if slices.Contains(state.RecentLog, "Upgrade complete.") {
t.Error("failed custom upgrade logged success")
}
if backend.checks != 0 {
t.Errorf("failed custom upgrade refreshed %d times", backend.checks)
}
}
+1 -1
View File
@@ -1 +1 @@
Heisenberg
Marble Tabby
+21 -5
View File
@@ -189,6 +189,26 @@ Singleton {
}
}
readonly property var _termIndexes: new WeakMap()
function _termIndex(table) {
const cached = _termIndexes.get(table);
if (cached)
return cached;
const index = {};
for (const c in table) {
const bucket = table[c];
if (!bucket)
continue;
for (const term in bucket) {
if (!index[term] && bucket[term])
index[term] = bucket[term];
}
}
_termIndexes.set(table, index);
return index;
}
function _lookup(table, term, context) {
if (!table)
return "";
@@ -196,11 +216,7 @@ Singleton {
return table[context][term];
if (table[term] && table[term][term])
return table[term][term];
for (const c in table) {
if (table[c] && table[c][term])
return table[c][term];
}
return "";
return _termIndex(table)[term] || "";
}
// isRealContext is consumed by translations/extract_translations.py only:
+10 -1
View File
@@ -3,6 +3,7 @@ import Quickshell
QtObject {
required property Singleton service
property var modules: null
property bool active: true
property bool _held: false
@@ -11,7 +12,15 @@ QtObject {
if (wanted === _held)
return;
_held = wanted;
service.refCount += wanted ? 1 : -1;
if (modules === null) {
service.refCount += wanted ? 1 : -1;
return;
}
if (wanted) {
service.addRef(modules);
return;
}
service.removeRef(modules);
}
onActiveChanged: sync(active)
@@ -37,7 +37,7 @@ Item {
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking && effectiveModalLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(effectiveScreen)
function _dockOccupiesSide(side) {
if (!SettingsData.showDock)
@@ -83,6 +83,12 @@ Item {
property bool keepPopoutsOpen: false
property var customKeyboardFocus: null
property bool useOverlayLayer: false
readonly property var effectiveModalLayer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
property real frozenMotionOffsetX: 0
property real frozenMotionOffsetY: 0
readonly property alias contentWindow: contentWindow
@@ -140,7 +146,7 @@ Item {
"phase": phase,
"visible": presented,
"presented": presented,
"layer": root.useOverlayLayer ? "overlay" : "top",
"layer": root.effectiveModalLayer === WlrLayer.Overlay ? "overlay" : "top",
"barSide": resolvedConnectedBarSide,
"bodyRect": bodyRect,
"animationOffset": animationOffset,
@@ -397,12 +403,7 @@ Item {
}
WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
WlrLayershell.layer: root.effectiveModalLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(shouldHaveFocus, customKeyboardFocus)
@@ -821,24 +821,15 @@ Item {
}
if (isCategoryFiltered) {
var categoryPluginId = AppSearchService.getPluginIdForCategory(appCategory);
if (categoryPluginId) {
var pluginCategoryItems = getPluginItems(categoryPluginId, "");
for (var i = 0; i < pluginCategoryItems.length; i++) {
allItems.push(pluginCategoryItems[i]);
}
} else {
var rawApps = AppSearchService.getAppsInCategory(appCategory);
for (var i = 0; i < rawApps.length; i++) {
allItems.push(getOrTransformApp(rawApps[i]));
}
// Also include core apps (DMS Settings etc.) that match this category
var allCoreApps = AppSearchService.getCoreApps("");
for (var i = 0; i < allCoreApps.length; i++) {
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
if (coreAppCats.indexOf(appCategory) !== -1)
allItems.push(transformCoreApp(allCoreApps[i]));
}
var rawApps = AppSearchService.getAppsInCategory(appCategory);
for (var i = 0; i < rawApps.length; i++) {
allItems.push(getOrTransformApp(rawApps[i]));
}
var allCoreApps = AppSearchService.getCoreApps("");
for (var i = 0; i < allCoreApps.length; i++) {
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
if (coreAppCats.indexOf(appCategory) !== -1)
allItems.push(transformCoreApp(allCoreApps[i]));
}
} else {
var apps = searchApps(searchQuery);
@@ -85,7 +85,7 @@ Item {
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && effectiveLauncherLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(effectiveScreen)
readonly property bool launcherArcExtenderActive: frameOwnsConnectedChrome && SettingsData.frameLauncherArcExtender && (resolvedConnectedBarSide === "top" || resolvedConnectedBarSide === "bottom")
function _dockOccupiesSide(side) {
@@ -246,7 +246,7 @@ Item {
"phase": phase,
"visible": presented,
"presented": presented,
"layer": root.usesOverlayLayer ? "overlay" : "top",
"layer": root.effectiveLauncherLayer === WlrLayer.Overlay ? "overlay" : "top",
"barSide": resolvedConnectedBarSide,
"bodyRect": bodyRect,
"animationOffset": animationOffset,
@@ -47,23 +47,20 @@ Rectangle {
Row {
id: leftContent
anchors.left: parent.left
anchors.leftMargin: Theme.spacingXS
anchors.leftMargin: hasAppCategories ? 0 : Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
// Whether the apps category picker should replace the plain title
readonly property bool hasAppCategories: root.section?.id === "apps" && (root.controller?.appCategories?.length ?? 0) > 0
DankIcon {
anchors.verticalCenter: parent.verticalCenter
// Hide section icon when the category chip already shows one
visible: !leftContent.hasAppCategories
name: root.section?.icon ?? "folder"
size: 16
color: Theme.surfaceVariantText
}
// Plain title — hidden when the category chip is shown
StyledText {
anchors.verticalCenter: parent.verticalCenter
visible: !leftContent.hasAppCategories
@@ -73,12 +70,10 @@ Rectangle {
color: Theme.surfaceVariantText
}
// Compact inline category chip — only visible on the apps section
Item {
id: categoryChip
visible: leftContent.hasAppCategories
anchors.verticalCenter: parent.verticalCenter
// Size to content with a fixed-min width so it doesn't jump around
width: chipRow.implicitWidth + Theme.spacingM * 2
height: 24
@@ -237,7 +232,6 @@ Rectangle {
}
}
// Size to list content, cap at 10 visible items
height: Math.min((root.controller?.appCategories?.length ?? 0) * 34, 10 * 34) + Theme.spacingS * 2 + 4
}
}
@@ -19,8 +19,11 @@ Rectangle {
signal settingsButtonClicked
signal headerTapped
Component.onCompleted: DgopService.addRef("system")
Component.onDestruction: DgopService.removeRef("system")
Ref {
service: DgopService
modules: "system"
active: root.visible && (root.Window.window?.visible ?? false)
}
implicitHeight: 70
radius: Theme.cornerRadius
@@ -19,7 +19,10 @@ Rectangle {
signal showPortSelector(var node)
Component.onCompleted: AudioService.refreshSinkPorts()
Component.onCompleted: {
AudioService.refreshSinkPorts();
AudioService.refreshCards();
}
implicitHeight: headerRow.height + (!hasVolumeSliderInCC ? volumeSlider.height : 0) + audioContent.height + Theme.spacingM
radius: Theme.cornerRadius
@@ -365,6 +368,78 @@ Rectangle {
}
}
}
Repeater {
model: ScriptModel {
values: AudioService.switchableOutputPorts
}
delegate: Rectangle {
id: portDelegate
required property var modelData
width: parent.width
height: 50
radius: Theme.cornerRadius
color: portMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
border.color: Theme.outlineLight
border.width: 1
DankRipple {
id: portRipple
cornerRadius: portDelegate.radius
}
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingS
DankIcon {
name: portDelegate.modelData.icon
size: Theme.iconSize - 4
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Theme.iconSize - parent.spacing
StyledText {
text: portDelegate.modelData.title
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
elide: Text.ElideRight
width: parent.width
wrapMode: Text.NoWrap
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: portDelegate.modelData.cardDescription
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideRight
width: parent.width
wrapMode: Text.NoWrap
horizontalAlignment: Text.AlignLeft
}
}
}
MouseArea {
id: portMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: mouse => portRipple.trigger(mouse.x, mouse.y)
onClicked: AudioService.activateOutputPort(portDelegate.modelData, (ok, message) => ToastService.showToast(message, ok ? ToastService.levelInfo : ToastService.levelError))
}
}
}
Row {
id: playbackHeaderRow
anchors.left: parent.left
@@ -20,12 +20,10 @@ Rectangle {
border.color: Theme.outlineMedium
border.width: Theme.layerOutlineWidth
Component.onCompleted: {
DgopService.addRef(["diskmounts"]);
}
Component.onDestruction: {
DgopService.removeRef(["diskmounts"]);
Ref {
service: DgopService
modules: ["diskmounts"]
active: root.visible && (root.Window.window?.visible ?? false)
}
DankFlickable {
@@ -67,11 +67,10 @@ CompoundPill {
return Theme.surfaceText;
}
Component.onCompleted: {
DgopService.addRef(["diskmounts"]);
}
Component.onDestruction: {
DgopService.removeRef(["diskmounts"]);
Ref {
service: DgopService
modules: ["diskmounts"]
active: root.visible && (root.Window.window?.visible ?? false)
}
onToggled: {
@@ -117,10 +117,9 @@ Rectangle {
onClicked: root.clicked()
}
Component.onCompleted: {
DgopService.addRef(["diskmounts"]);
}
Component.onDestruction: {
DgopService.removeRef(["diskmounts"]);
Ref {
service: DgopService
modules: ["diskmounts"]
active: root.visible && (root.Window.window?.visible ?? false)
}
}
@@ -39,6 +39,7 @@ Item {
applyIndexLayout();
}
updateContentExtent();
blurBarWindow?.refreshBlurRegion?.();
}
function updateContentExtent() {
+25 -6
View File
@@ -164,6 +164,12 @@ Item {
_blurRebuildTimer.restart();
}
function refreshBlurRegion() {
if (!blurRegion)
return;
blurRegion.changed();
}
Timer {
id: _blurRebuildTimer
interval: 1
@@ -386,12 +392,26 @@ Item {
Connections {
target: topBarSlide
function onXChanged() {
if (barWindow.blurRegion)
barWindow.blurRegion.changed();
barWindow.refreshBlurRegion();
}
function onYChanged() {
if (barWindow.blurRegion)
barWindow.blurRegion.changed();
barWindow.refreshBlurRegion();
}
}
Connections {
target: barUnitInset
function onXChanged() {
barWindow.refreshBlurRegion();
}
function onYChanged() {
barWindow.refreshBlurRegion();
}
function onWidthChanged() {
barWindow.refreshBlurRegion();
}
function onHeightChanged() {
barWindow.refreshBlurRegion();
}
}
@@ -401,8 +421,7 @@ Item {
_blurRebuildTimer.restart();
}
function onWingChanged() {
if (barWindow.blurRegion)
barWindow.blurRegion.changed();
barWindow.refreshBlurRegion();
}
}
@@ -25,6 +25,13 @@ Item {
readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false)
property alias widgetLayoutLoader: layoutLoader
function refreshBlur() {
blurBarWindow?.refreshBlurRegion?.();
}
onXChanged: refreshBlur()
onYChanged: refreshBlur()
implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0
implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0
@@ -52,6 +59,7 @@ Item {
visible: widgetLoader.active && widgetLoader.widgetEnabled
width: widgetLoader.item ? widgetLoader.item.width : 0
height: widgetLoader.item ? widgetLoader.item.height : 0
onXChanged: root.refreshBlur()
WidgetHost {
id: widgetLoader
anchors.verticalCenter: parent.verticalCenter
@@ -100,6 +108,7 @@ Item {
property var itemData: modelData
visible: widgetLoader.active && widgetLoader.widgetEnabled
height: widgetLoader.item ? widgetLoader.item.height : 0
onYChanged: root.refreshBlur()
WidgetHost {
id: widgetLoader
anchors.horizontalCenter: parent.horizontalCenter
@@ -25,6 +25,13 @@ Item {
readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false)
property alias widgetLayoutLoader: layoutLoader
function refreshBlur() {
blurBarWindow?.refreshBlurRegion?.();
}
onXChanged: refreshBlur()
onYChanged: refreshBlur()
implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0
implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0
@@ -54,6 +61,7 @@ Item {
visible: widgetLoader.active && widgetLoader.widgetEnabled
width: widgetLoader.item ? widgetLoader.item.width : 0
height: widgetLoader.item ? widgetLoader.item.height : 0
onXChanged: root.refreshBlur()
WidgetHost {
id: widgetLoader
anchors.verticalCenter: parent.verticalCenter
@@ -102,6 +110,7 @@ Item {
property var itemData: modelData
visible: widgetLoader.active && widgetLoader.widgetEnabled
height: widgetLoader.item ? widgetLoader.item.height : 0
onYChanged: root.refreshBlur()
WidgetHost {
id: widgetLoader
anchors.horizontalCenter: parent.horizontalCenter
@@ -1899,6 +1899,8 @@ BasePill {
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
elide: Text.ElideMiddle
wrapMode: Text.NoWrap
maximumLineCount: 1
width: parent.width - Theme.spacingS * 2 - (Theme.iconSizeSmall + Theme.spacingS)
}
@@ -26,44 +26,19 @@ Item {
readonly property bool isMango: CompositorService.isMango
readonly property bool useAqueous: CompositorService.isAqueous && AqueousService.available && Quickshell.env("DMS_FORCE_EXTWS") !== "1"
readonly property real _leftMargin: {
if (isVertical)
return 0;
root.x;
if (!root.parent)
return 0;
const gap = root.mapToItem(null, 0, 0).x;
return (gap > 0 && gap < 30) ? gap + 5 : 0;
}
readonly property real _rightMargin: {
if (isVertical)
return 0;
root.x;
root.width;
if (!root.parent || !blurBarWindow)
return 0;
const gap = blurBarWindow.width - root.mapToItem(null, root.width, 0).x;
return (gap > 0 && gap < 30) ? gap + 5 : 0;
}
readonly property real _topMargin: {
if (!isVertical)
return axis?.edge === "top" ? crossEdgeExtension : 0;
root.y;
if (!root.parent)
return 0;
const gap = root.mapToItem(null, 0, 0).y;
return (gap > 0 && gap < 30) ? gap + 5 : 0;
}
readonly property real _bottomMargin: {
if (!isVertical)
return axis?.edge === "bottom" ? crossEdgeExtension : 0;
root.y;
root.height;
if (!root.parent || !blurBarWindow)
return 0;
const gap = blurBarWindow.height - root.mapToItem(null, 0, root.height).y;
return (gap > 0 && gap < 30) ? gap + 5 : 0;
}
property bool isFirst: false
property bool isLast: false
property real sectionSpacing: 0
property bool isLeftBarEdge: false
property bool isRightBarEdge: false
property bool isTopBarEdge: false
property bool isBottomBarEdge: false
readonly property real barEdgeExtension: 1000
readonly property real _leftMargin: isVertical ? 0 : (isLeftBarEdge && isFirst ? barEdgeExtension : (isFirst ? sectionSpacing : sectionSpacing / 2))
readonly property real _rightMargin: isVertical ? 0 : (isRightBarEdge && isLast ? barEdgeExtension : (isLast ? sectionSpacing : sectionSpacing / 2))
readonly property real _topMargin: isVertical ? (isTopBarEdge && isFirst ? barEdgeExtension : (isFirst ? sectionSpacing : sectionSpacing / 2)) : (axis?.edge === "top" ? crossEdgeExtension : 0)
readonly property real _bottomMargin: isVertical ? (isBottomBarEdge && isLast ? barEdgeExtension : (isLast ? sectionSpacing : sectionSpacing / 2)) : (axis?.edge === "bottom" ? crossEdgeExtension : 0)
property int _desktopEntriesUpdateTrigger: 0
readonly property var sortedToplevels: {
@@ -166,6 +166,9 @@ Item {
function unregisterBlurWidget(item) {
root.unregisterBlurWidget(item);
}
function refreshBlurRegion() {
root.kickBlur();
}
}
property var _blurWidgetItems: []
@@ -179,10 +179,10 @@ Item {
SettingsToggleRow {
settingKey: "systemUpdaterAUR"
tags: ["aur", "paru", "yay"]
tags: ["aur", "paru", "yay", "shelly"]
text: I18n.tr("Include AUR updates")
description: I18n.tr("Run paru/yay with AUR enabled when 'Update All' is clicked.")
visible: (SystemUpdateService.backends || []).some(b => b.id === "paru" || b.id === "yay")
description: I18n.tr("Apply AUR updates alongside system updates when running 'Update All'.")
visible: (SystemUpdateService.backends || []).some(b => ["paru", "yay", "shelly"].includes(b.id))
checked: SettingsData.updaterAllowAUR
onToggled: checked => SettingsData.set("updaterAllowAUR", checked)
}
@@ -198,17 +198,21 @@ Item {
settingKey: "systemUpdaterIgnoredPackages"
tags: ["system", "update", "package", "ignore"]
property bool errorIsInvalidName: false
function addIgnoredPackage() {
const name = newIgnoredPackageField.text.trim();
if (name === "") {
return;
}
if (!/^[A-Za-z0-9@._+:-]+$/.test(name)) {
errorIsInvalidName = !/^[A-Za-z0-9@._+:-]+$/.test(name);
if (errorIsInvalidName) {
ignoredPackageError.visible = true;
return;
}
ignoredPackageError.visible = false;
SystemUpdateService.ignorePackage(name);
ignoredPackageError.visible = !SystemUpdateService.ignorePackage(name);
if (ignoredPackageError.visible)
return;
newIgnoredPackageField.text = "";
}
@@ -222,6 +226,9 @@ Item {
if (SettingsData.updaterUseCustomCommand) {
return I18n.tr("Ignored packages only apply to the built-in updater. Your custom command controls its own exclusions.");
}
if (SystemUpdateService.pkgManager === "shelly") {
return I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored.");
}
return (SettingsData.updaterIgnoredPackages || []).length > 0 ? I18n.tr("Ignored packages are hidden from the updater and skipped by 'Update All'.") : I18n.tr("No packages ignored. Add one here or hover an update in the popout and click the hide button.");
}
font.pixelSize: Theme.fontSizeSmall
@@ -258,10 +265,12 @@ Item {
StyledText {
id: ignoredPackageError
width: parent.width
visible: false
text: I18n.tr("Invalid package name — letters, digits and @._+:- only.")
text: ignoredPackagesCard.errorIsInvalidName ? I18n.tr("Invalid package name — letters, digits and @._+:- only.") : I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
wrapMode: Text.WordWrap
}
SettingsCard {
+11 -14
View File
@@ -769,11 +769,7 @@ Singleton {
return results;
}
function getCategoriesForApp(app) {
if (!app?.categories)
return [];
const categoryMap = {
readonly property var _categoryMap: ({
"AudioVideo": I18n.tr("Media"),
"Audio": I18n.tr("Media"),
"Video": I18n.tr("Media"),
@@ -798,15 +794,20 @@ Singleton {
"Accessories": I18n.tr("Utilities"),
"FileManager": I18n.tr("Utilities"),
"TerminalEmulator": I18n.tr("Utilities")
};
})
on_CategoryMapChanged: _cachedCategories = null
function getCategoriesForApp(app) {
if (!app?.categories)
return [];
const mappedCategories = new Set();
for (const cat of app.categories) {
if (categoryMap[cat])
mappedCategories.add(categoryMap[cat]);
const mapped = _categoryMap[cat];
if (mapped)
mappedCategories.add(mapped);
}
return Array.from(mappedCategories);
}
@@ -842,15 +843,11 @@ Singleton {
appCategories.forEach(cat => categories.add(cat));
}
// Include categories from core apps (e.g. DMS Settings)
for (const app of coreApps) {
const appCategories = getCategoriesForApp(app);
appCategories.forEach(cat => categories.add(cat));
}
const pluginCategories = getPluginCategories();
pluginCategories.forEach(cat => categories.add(cat));
_cachedCategories = Array.from(categories).sort();
return _cachedCategories;
}
+223
View File
@@ -56,6 +56,9 @@ Singleton {
property bool wireplumberReloading: false
property var sinkPorts: ({})
property var cards: []
property var pendingCardSwitch: null
readonly property var switchableOutputPorts: root.collectSwitchableOutputPorts(root.cards)
readonly property int sinkMaxVolume: {
const name = sink?.name ?? "";
@@ -127,6 +130,7 @@ Singleton {
target: Pipewire.nodes
function onValuesChanged() {
root.rebuildTypedNodeLists();
root.adoptPendingCardSink();
}
}
@@ -271,6 +275,225 @@ Singleton {
return result;
}
function refreshCards(callback) {
Proc.runCommand("audio-list-cards", ["env", "LC_ALL=C", "pactl", "list", "cards"], (output, exitCode) => {
root.cards = exitCode === 0 ? root.parseCards(output) : [];
if (callback)
callback();
}, 0);
}
function parseCards(text) {
const cards = [];
let card = null;
let port = null;
let section = "";
function commit() {
if (!card?.name)
return;
const props = card.props;
card.description = props["device.description"] || props["device.nick"] || props["alsa.card_name"] || card.name;
cards.push(card);
}
function applyProperty(target, line) {
const match = line.match(/^([^=\s]+)\s*=\s*"(.*)"$/);
if (match)
target[match[1]] = match[2];
}
function addProfile(target, line) {
const match = line.match(/^(.+?):\s+(.*)\s+\(([^()]*)\)$/);
if (!match)
return;
const meta = match[3];
target.profiles[match[1]] = {
description: match[2],
priority: parseInt(meta.match(/priority:?\s*(\d+)/)?.[1] ?? "0", 10),
available: !/available:\s*no/.test(meta)
};
}
function addPort(target, line) {
const match = line.match(/^(.+?):\s+(.*)\s+\(([^()]*)\)$/);
if (!match)
return null;
const meta = match[3];
let availability = "unknown";
if (meta.includes("not available"))
availability = "no";
else if (/\bavailable\b/.test(meta))
availability = "yes";
const entry = {
name: match[1],
description: match[2],
type: (meta.match(/type:\s*([^,]+)/)?.[1] ?? "").trim().toLowerCase(),
availability: availability,
profiles: [],
props: {}
};
target.ports.push(entry);
return entry;
}
for (const rawLine of (text || "").split("\n")) {
const line = rawLine.trim();
if (/^Card #\d+/.test(line)) {
commit();
card = {
name: "",
description: "",
activeProfile: "",
profiles: {},
ports: [],
props: {}
};
port = null;
section = "";
continue;
}
if (!card)
continue;
if (line.startsWith("Name:")) {
card.name = line.substring(5).trim();
section = "";
continue;
}
if (line === "Properties:") {
section = port ? "portProps" : "cardProps";
continue;
}
if (line === "Profiles:") {
section = "profiles";
continue;
}
if (line === "Ports:") {
section = "ports";
continue;
}
if (line.startsWith("Active Profile:")) {
card.activeProfile = line.substring(15).trim();
section = "";
continue;
}
if (line.startsWith("Part of profile(s):")) {
if (port)
port.profiles = line.substring(19).split(",").map(s => s.trim()).filter(s => s);
section = "ports";
continue;
}
switch (section) {
case "cardProps":
applyProperty(card.props, line);
break;
case "portProps":
if (port)
applyProperty(port.props, line);
break;
case "profiles":
addProfile(card, line);
break;
case "ports":
port = addPort(card, line) ?? port;
break;
}
}
commit();
return cards;
}
function bestOutputProfile(card, port) {
const candidates = port.profiles.filter(name => name.startsWith("output:") && card.profiles[name]?.available);
if (candidates.length === 0)
return "";
candidates.sort((a, b) => card.profiles[b].priority - card.profiles[a].priority);
return candidates[0];
}
function collectSwitchableOutputPorts(cards) {
const entries = [];
for (const card of cards || []) {
if (!card.activeProfile || card.activeProfile === "pro-audio")
continue;
for (const port of card.ports) {
if (port.availability === "no" || port.profiles.includes(card.activeProfile))
continue;
const profile = bestOutputProfile(card, port);
if (!profile)
continue;
const product = port.props["device.product.name"] || "";
entries.push({
cardName: card.name,
cardDescription: card.description,
portName: port.name,
profile: profile,
icon: port.type === "hdmi" ? "tv" : "speaker",
title: product ? `${port.description} [${product}]` : port.description
});
}
}
return entries;
}
function sinkBelongsToCard(node, cardName) {
if (!node || node.isStream || !cardName)
return false;
if (node.properties?.["device.name"] === cardName)
return true;
if (!cardName.startsWith("alsa_card."))
return false;
return (node.name || "").startsWith("alsa_output." + cardName.substring(10) + ".");
}
function activateOutputPort(entry, callback) {
if (!entry?.cardName || !entry.profile)
return;
const previousSinks = Pipewire.nodes.values.filter(n => n.isSink && sinkBelongsToCard(n, entry.cardName)).map(n => n.name);
Proc.runCommand("audio-set-card-profile", ["env", "LC_ALL=C", "pactl", "set-card-profile", entry.cardName, entry.profile], (output, exitCode) => {
const ok = exitCode === 0;
if (ok) {
root.pendingCardSwitch = {
cardName: entry.cardName,
portName: entry.portName,
exclude: previousSinks
};
pendingCardSwitchTimer.restart();
root.adoptPendingCardSink();
}
if (callback)
callback(ok, ok ? I18n.tr("Output switched", "audio card profile switched successful message") : (output || I18n.tr("Failed to switch output", "audio card profile switch failure message")));
Qt.callLater(() => {
root.refreshCards();
root.refreshSinkPorts();
});
}, 0);
}
function adoptPendingCardSink() {
const pending = pendingCardSwitch;
if (!pending)
return;
const node = Pipewire.nodes.values.find(n => n.isSink && sinkBelongsToCard(n, pending.cardName) && !pending.exclude.includes(n.name));
if (!node)
return;
pendingCardSwitch = null;
pendingCardSwitchTimer.stop();
setSink(node);
setSinkPort(node.name, pending.portName);
}
Timer {
id: pendingCardSwitchTimer
interval: 5000
onTriggered: root.pendingCardSwitch = null
}
function cycleAudioOutputDirection(forward) {
const sinks = getAvailableSinks();
if (sinks.length < 2)
+1 -1
View File
@@ -13,7 +13,7 @@ Singleton {
readonly property var log: Log.scoped("ChangelogService")
readonly property string currentVersion: "1.6"
readonly property bool changelogEnabled: false
readonly property bool changelogEnabled: true
readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/DankMaterialShell"
readonly property string changelogMarkerPath: configDir + "/.changelog-" + currentVersion
+15
View File
@@ -730,6 +730,21 @@ Singleton {
return FrameTransitionState.effectiveConnectedFrameModeActive && frameWindowVisibleForScreen(screenOrName);
}
function canShareConnectedFrameChromeForScreen(screenOrName) {
if (!usesConnectedFrameChromeForScreen(screenOrName))
return false;
if (!isHyprland)
return true;
const screenName = _screenName(screenOrName);
const monitor = Hyprland.monitors.values.find(m => m.name === screenName);
const specialWorkspace = monitor?.lastIpcObject?.specialWorkspace?.name;
const workspace = specialWorkspace ? Hyprland.workspaces.values.find(w => w.name === specialWorkspace) : monitor?.activeWorkspace;
if (!workspace)
return true;
return !workspace.toplevels.values.some(t => t.lastIpcObject?.fullscreen === 2);
}
// Connected mode renders the bar inside the frame surface.
function frameHostsSurfacesForScreen(screenOrName) {
return FrameTransitionState.effectiveConnectedFrameModeActive && frameConfiguredForScreen(screenOrName);
+3
View File
@@ -78,7 +78,10 @@ Singleton {
function onLowerDisplayRefreshRateOnBatteryChanged() {
root.requestSync("setting-change");
}
}
Connections {
target: SessionData
function onActiveDisplayProfileChanged() {
root.requestSync("profile-change");
}
+9 -6
View File
@@ -35,14 +35,14 @@ Singleton {
readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0
readonly property bool useCustomCommand: SettingsData.updaterUseCustomCommand && (SettingsData.updaterCustomCommand || "").trim().length > 0
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
// AUR/Flatpak packages stay ignorable — holding those cannot break the repo dependency graph.
readonly property bool systemHoldsAllowed: !["pacman", "paru", "yay"].includes(pkgManager)
// Arch repository packages must upgrade together.
readonly property bool systemHoldsAllowed: !["pacman", "paru", "yay", "shelly"].includes(pkgManager)
function canIgnorePackage(pkg) {
if (!pkg)
return false;
if (pkgManager === "shelly")
return pkg.repo === "flatpak";
return systemHoldsAllowed || pkg.repo !== "system";
}
@@ -181,12 +181,15 @@ Singleton {
function ignorePackage(name) {
if (!name)
return;
return false;
if (pkgManager === "shelly" && !_rawUpdates.some(p => p.name === name && canIgnorePackage(p)))
return false;
const list = (SettingsData.updaterIgnoredPackages || []).slice();
if (list.indexOf(name) !== -1)
return;
return true;
list.push(name);
SettingsData.set("updaterIgnoredPackages", list);
return true;
}
function unignorePackage(name) {
+1 -1
View File
@@ -1 +1 @@
v1.7-beta
v1.6.1
+2 -2
View File
@@ -266,7 +266,7 @@ Item {
"phase": phase,
"visible": visible,
"presented": presented,
"layer": root.triggerUsesOverlayLayer ? "overlay" : "top",
"layer": root.effectivePopoutLayer === WlrLayer.Overlay ? "overlay" : "top",
"barSide": contentContainer.connectedBarSide,
"bodyRect": bodyRect,
"animationOffset": animationOffset,
@@ -423,7 +423,7 @@ Item {
}
}
readonly property bool frameOwnsConnectedChrome: CompositorService.usesConnectedFrameChromeForScreen(root.screen)
readonly property bool frameOwnsConnectedChrome: effectivePopoutLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(root.screen)
readonly property bool usesConnectedSurfaceChrome: Theme.isConnectedEffect
readonly property bool usesLocalConnectedSurfaceChrome: usesConnectedSurfaceChrome && !frameOwnsConnectedChrome
onFrameOwnsConnectedChromeChanged: _syncPopoutChromeState()
+325 -51
View File
@@ -4,6 +4,7 @@ import sys
import json
import os
import subprocess
import time
from pathlib import Path
from urllib import request, parse
@@ -22,13 +23,28 @@ COMMON_POEXPORTS_DIR = COMMON_ROOT / "DankCommon" / "translations" / "poexports"
# Plugin checkouts under quickshell/ are scanned by extraction (terms tagged
# plugin-<dir>); their per-language exports are written back into each
# checkout's translations/ dir, which ships with the plugin repo.
PLUGIN_CHECKOUT_DIRS = [REPO_ROOT / "dms-plugins", REPO_ROOT / "dms-plugins-external"]
# checkout's translations/ dir, which ships with the plugin repo. sync
# clones and fast-forwards them: the official monorepo plus every registry
# plugin flagged "i18n": true, keyed by plugin id.
OFFICIAL_PLUGINS_REPO = "https://github.com/AvengeMedia/dms-plugins.git"
PLUGIN_REGISTRY_REPO = "https://github.com/AvengeMedia/dms-plugin-registry.git"
OFFICIAL_PLUGINS_DIR = REPO_ROOT / "dms-plugins"
EXTERNAL_PLUGINS_DIR = REPO_ROOT / "dms-plugins-external"
REGISTRY_DIR = EXTERNAL_PLUGINS_DIR / ".registry"
PLUGIN_CHECKOUT_DIRS = [OFFICIAL_PLUGINS_DIR, EXTERNAL_PLUGINS_DIR]
PLUGIN_PR_BRANCH = "i18n/poeditor-sync"
PLUGIN_PR_TITLE = "i18n: sync translations from POEditor"
# Flip once official plugins ship their own translations/ dirs: app poexports
# then stop carrying terms owned exclusively by plugins.
EXCLUDE_PLUGIN_ONLY_TERMS = False
RATE_LIMIT_CODE = '4048'
UPLOAD_MIN_INTERVAL = 25
UPLOAD_RETRIES = 4
_last_upload = 0.0
LANGUAGES = {
"ja": "ja.json",
"zh-Hans": "zh_CN.json",
@@ -138,6 +154,45 @@ def load_greeter_entries(api_token, project_id):
def entry_keys(entries):
return {(e.get('context') or e['term'], e['term']) for e in entries}
def git(args, cwd):
result = subprocess.run(['git', *args], cwd=cwd, capture_output=True, text=True)
if result.returncode != 0:
error(f"git {' '.join(args)} in {cwd} failed:\n{result.stderr.strip()}")
def clone_or_update(url, path):
rel = path.relative_to(REPO_ROOT)
if not (path / '.git').is_dir():
info(f"Cloning {url} -> {rel}")
path.parent.mkdir(parents=True, exist_ok=True)
git(['clone', '--quiet', url, str(path)], REPO_ROOT)
return
info(f"Updating {rel}")
git(['pull', '--quiet', '--ff-only'], path)
def i18n_registry_plugins():
plugins = []
for entry_file in sorted((REGISTRY_DIR / 'plugins').glob('*.json')):
with open(entry_file) as f:
entry = json.load(f)
if entry.get('i18n') is not True:
continue
if not entry.get('id') or not entry.get('repo'):
error(f"Registry entry {entry_file.name} has i18n set but no id or repo")
plugins.append((entry['id'], entry['repo']))
return plugins
def update_plugin_checkouts():
clone_or_update(OFFICIAL_PLUGINS_REPO, OFFICIAL_PLUGINS_DIR)
clone_or_update(PLUGIN_REGISTRY_REPO, REGISTRY_DIR)
plugins = i18n_registry_plugins()
for plugin_id, repo in plugins:
clone_or_update(repo, EXTERNAL_PLUGINS_DIR / plugin_id)
approved = {plugin_id for plugin_id, _ in plugins}
for child in EXTERNAL_PLUGINS_DIR.iterdir():
if child.is_dir() and not child.name.startswith('.') and child.name not in approved:
warn(f"dms-plugins-external/{child.name} is not flagged i18n in the registry but its terms still get uploaded; remove it if it was unapproved")
success(f"Plugin checkouts current: official + {len(plugins)} registry plugins")
def plugin_checkouts():
checkouts = {}
for base in PLUGIN_CHECKOUT_DIRS:
@@ -148,6 +203,134 @@ def plugin_checkouts():
checkouts['plugin-' + child.name.lower()] = child
return checkouts
def gh(args, cwd=None, required=True):
result = subprocess.run(['gh', *args], cwd=cwd, capture_output=True, text=True)
if result.returncode == 0:
return result.stdout.strip()
if required:
error(f"gh {' '.join(args)} failed:\n{result.stderr.strip()}")
return None
def git_output(args, cwd):
result = subprocess.run(['git', *args], cwd=cwd, capture_output=True, text=True)
if result.returncode != 0:
return None
return result.stdout.strip()
def repo_slug(checkout):
url = git_output(['remote', 'get-url', 'origin'], checkout) or ""
if 'github.com' not in url:
return None
return url.removesuffix('.git').split('github.com', 1)[1].lstrip(':/')
def default_branch(checkout):
ref = git_output(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], checkout)
if not ref:
return 'main'
return ref.split('/', 1)[-1]
def pending_translation_files(checkout):
out = git_output(['status', '--porcelain', '--untracked-files=all', '--', 'translations'], checkout)
if not out:
return []
names = (line[3:].rsplit('/', 1)[-1] for line in out.splitlines())
return sorted(name for name in names if name.endswith('.json'))
def pending_plugin_prs():
external = []
official = []
for checkout in sorted(plugin_checkouts().values(), key=lambda path: path.name):
files = pending_translation_files(checkout)
if not files:
continue
if checkout.parent == EXTERNAL_PLUGINS_DIR:
external.append((checkout, files))
else:
official.append((checkout, files))
return external, official
def plugin_pr_body(files):
return (
"Translations synced from the DMS POEditor project, where this plugin's strings "
"are tagged and translated alongside the shell.\n\n"
f"Updated: {', '.join(sorted(files))}\n\n"
"Strings this repo already shipped were uploaded to POEditor before the export, "
"so existing translations are preserved rather than overwritten.\n"
)
def open_plugin_pr(checkout, files):
slug = repo_slug(checkout)
if not slug:
warn(f"{checkout.name}: origin is not a github remote, skipping PR")
return
login = gh(['api', 'user', '-q', '.login'])
fork = f"{login}/{slug.split('/', 1)[1]}"
if gh(['repo', 'view', fork, '--json', 'name'], required=False) is None:
info(f"Forking {slug} -> {fork}")
gh(['repo', 'fork', slug, '--clone=false', '--remote=false'])
base = default_branch(checkout)
git(['checkout', '--quiet', '-B', PLUGIN_PR_BRANCH], checkout)
git(['add', 'translations'], checkout)
committed = subprocess.run(
['git', 'commit', '--quiet', '-m', PLUGIN_PR_TITLE],
cwd=checkout, capture_output=True, text=True
)
if committed.returncode != 0:
git(['checkout', '--quiet', base], checkout)
warn(f"{checkout.name}: nothing to commit, skipping PR")
return
git(['push', '--quiet', '--force', f'[email protected]:{fork}.git', PLUGIN_PR_BRANCH], checkout)
git(['checkout', '--quiet', base], checkout)
existing = gh([
'pr', 'list', '--repo', slug, '--head', f'{login}:{PLUGIN_PR_BRANCH}',
'--state', 'open', '--json', 'url', '-q', '.[0].url'
], required=False)
if existing:
success(f"{checkout.name}: updated {existing}")
return
url = gh([
'pr', 'create', '--repo', slug, '--base', base, '--head', f'{login}:{PLUGIN_PR_BRANCH}',
'--title', PLUGIN_PR_TITLE, '--body', plugin_pr_body(files)
])
success(f"{checkout.name}: opened {url}")
def checkout_translations(checkout, filename):
result = subprocess.run(
['git', 'show', f'HEAD:translations/{filename}'],
cwd=checkout, capture_output=True, text=True
)
if result.returncode != 0:
return {}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
warn(f"{checkout.name}/translations/{filename} is not valid JSON in git HEAD")
return {}
def keep_existing_translations(existing, incoming):
return {
context: {term: value or existing.get(context, {}).get(term, "") for term, value in bucket.items()}
for context, bucket in incoming.items()
}
def missing_from_poeditor(existing, incoming):
gaps = {}
for context, bucket in incoming.items():
for term, value in bucket.items():
if value:
continue
local = existing.get(context, {}).get(term, "")
if not local:
continue
gaps[(context, term)] = local
return gaps
def plugin_term_owners(entries):
owners = {}
excluded = set()
@@ -194,6 +377,63 @@ def split_export(data, common_keys, greeter_keys, plugin_owners, plugin_excluded
app_part.setdefault(context, {})[term] = value
return app_part, common_part, plugin_parts
def _throttle_upload():
global _last_upload
gap = UPLOAD_MIN_INTERVAL - (time.monotonic() - _last_upload)
if gap <= 0:
return
info(f"Waiting {gap:.0f}s for the POEditor upload rate limit...")
time.sleep(gap)
def poeditor_upload(fields, payload, filename, required=True):
global _last_upload
boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW'
head = ''.join(
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
f'{value}\r\n'
for name, value in fields.items()
) + (
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
f'Content-Type: application/json\r\n\r\n'
)
body = head.encode() + json.dumps(payload, ensure_ascii=False).encode() + f'\r\n--{boundary}--\r\n'.encode()
for attempt in range(UPLOAD_RETRIES):
_throttle_upload()
req = request.Request(
'https://api.poeditor.com/v2/projects/upload',
data=body,
headers={'Content-Type': f'multipart/form-data; boundary={boundary}'}
)
try:
with request.urlopen(req) as response:
result = json.loads(response.read().decode())
except Exception as e:
_last_upload = time.monotonic()
if required:
error(f"Upload failed: {e}")
warn(f"Upload failed: {e}")
return None
_last_upload = time.monotonic()
if result.get('response', {}).get('status') == 'success':
return result.get('result', {})
if result.get('response', {}).get('code') != RATE_LIMIT_CODE:
break
if attempt + 1 < UPLOAD_RETRIES:
_last_upload += UPLOAD_MIN_INTERVAL * (attempt + 1)
warn(f"POEditor rate limited the upload, retrying ({attempt + 2}/{UPLOAD_RETRIES})")
if required:
error(f"POEditor upload failed: {result}")
warn(f"POEditor upload failed: {result}")
return None
def upload_source_strings(api_token, project_id, entries, prune=False):
if not entries:
warn("No terms to upload")
@@ -201,56 +441,48 @@ def upload_source_strings(api_token, project_id, entries, prune=False):
info("Uploading source strings to POEditor..." + (" (pruning terms not present locally)" if prune else ""))
upload_bytes = json.dumps(entries, ensure_ascii=False).encode()
boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW'
sync_part = (
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="sync_terms"\r\n\r\n'
f'1\r\n'
) if prune else ''
body = (
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="api_token"\r\n\r\n'
f'{api_token}\r\n'
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="id"\r\n\r\n'
f'{project_id}\r\n'
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="updating"\r\n\r\n'
f'terms\r\n'
f'{sync_part}'
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="file"; filename="en.json"\r\n'
f'Content-Type: application/json\r\n\r\n'
).encode() + upload_bytes + f'\r\n--{boundary}--\r\n'.encode()
fields = {'api_token': api_token, 'id': project_id, 'updating': 'terms'}
if prune:
fields['sync_terms'] = '1'
req = request.Request(
'https://api.poeditor.com/v2/projects/upload',
data=body,
headers={'Content-Type': f'multipart/form-data; boundary={boundary}'}
)
try:
with request.urlopen(req) as response:
result = json.loads(response.read().decode())
except Exception as e:
error(f"Upload failed: {e}")
if result.get('response', {}).get('status') != 'success':
error(f"POEditor upload failed: {result}")
terms = result.get('result', {}).get('terms', {})
terms = poeditor_upload(fields, entries, 'en.json').get('terms', {})
added = terms.get('added', 0)
updated = terms.get('updated', 0)
deleted = terms.get('deleted', 0)
if added or updated or deleted:
success(f"POEditor updated: {added} added, {updated} updated, {deleted} deleted")
return True
else:
if not (added or updated or deleted):
info("No changes uploaded to POEditor")
return False
success(f"POEditor updated: {added} added, {updated} updated, {deleted} deleted")
return True
def seed_plugin_translations(api_token, project_id, plugin_seed):
seeded = {}
for po_lang, values in sorted(plugin_seed.items()):
entries = [
{'term': term, 'context': context, 'definition': value}
for (context, term), value in sorted(values.items())
]
info(f"Seeding {len(entries)} plugin-authored translations into POEditor ({po_lang})...")
result = poeditor_upload({
'api_token': api_token,
'id': project_id,
'updating': 'translations',
'language': po_lang,
'overwrite': '0',
'fuzzy_trigger': '0'
}, entries, LANGUAGES[po_lang], required=False)
if not result:
continue
translations = result.get('translations', {})
applied = translations.get('added', 0) + translations.get('updated', 0)
if not applied:
warn(f"POEditor accepted the {po_lang} seed but applied nothing: {translations}")
continue
seeded[po_lang] = applied
return seeded
def write_if_changed(repo_file, new_data):
if not json_changed(repo_file, new_data):
return False
@@ -268,6 +500,7 @@ def download_translations(api_token, project_id, common_keys, greeter_keys, plug
any_changed = False
common_changed = []
plugin_changed = {}
plugin_seed = {}
for po_lang, filename in LANGUAGES.items():
repo_file = POEXPORTS_DIR / filename
@@ -316,11 +549,15 @@ def download_translations(api_token, project_id, common_keys, greeter_keys, plug
continue
target_dir = checkout / "translations"
target_dir.mkdir(parents=True, exist_ok=True)
if write_if_changed(target_dir / filename, part):
existing = checkout_translations(checkout, filename)
gaps = missing_from_poeditor(existing, part)
if gaps:
plugin_seed.setdefault(po_lang, {}).update(gaps)
if write_if_changed(target_dir / filename, keep_existing_translations(existing, part)):
success(f"Updated {checkout.name} {filename}")
plugin_changed.setdefault(checkout.name, []).append(filename)
return any_changed, common_changed, plugin_changed
return any_changed, common_changed, plugin_changed, plugin_seed
def check_sync_status():
api_token = get_env_or_error('POEDITOR_API_TOKEN')
@@ -400,7 +637,7 @@ def save_sync_state():
def main():
if len(sys.argv) < 2:
error("Usage: i18nsync.py [check|sync [--prune]|test|local]")
error("Usage: i18nsync.py [check|sync [--prune] [--pr] [--seed]|pr|test|local]")
command = sys.argv[1]
@@ -438,9 +675,11 @@ def main():
api_token = get_env_or_error('POEDITOR_API_TOKEN')
project_id = get_env_or_error('POEDITOR_PROJECT_ID')
prune = "--prune" in sys.argv[2:]
open_prs = "--pr" in sys.argv[2:]
seed = "--seed" in sys.argv[2:]
if prune:
warn("--prune deletes every POEditor term missing from the local en.json, including its translations.")
warn("Terms from dms-plugins/ and dms-plugins-external/ are machine-dependent: make sure all official and approved external plugins are present before pruning.")
warn("Plugin checkouts are refreshed from the registry first, so pruning keeps terms of official and i18n-approved plugins; a plugin removed from the registry loses its terms.")
warn("dank-qml-common terms are included from the submodule, so pruning keeps them as long as the submodule is current.")
warn("dms-greeter terms are fetched from POEditor and re-included, so pruning keeps them.")
@@ -449,6 +688,7 @@ def main():
greeter_entries = load_greeter_entries(api_token, project_id)
greeter_keys = entry_keys(greeter_entries)
update_plugin_checkouts()
extract_strings()
current_en = normalize_json(EN_JSON)
@@ -482,7 +722,7 @@ def main():
info("No changes in source strings")
plugin_owners, plugin_excluded = plugin_term_owners(current_en)
translations_changed, common_files_changed, plugin_files_changed = download_translations(api_token, project_id, common_keys, greeter_keys, plugin_owners, plugin_excluded)
translations_changed, common_files_changed, plugin_files_changed, plugin_seed = download_translations(api_token, project_id, common_keys, greeter_keys, plugin_owners, plugin_excluded)
if strings_changed or translations_changed:
subprocess.run(['git', 'add', 'translations/'], cwd=REPO_ROOT)
@@ -498,8 +738,42 @@ def main():
for checkout_name, files in sorted(plugin_files_changed.items()):
info(f"{checkout_name} translations updated: {', '.join(files)}")
if plugin_files_changed:
info("Commit those in each plugin checkout - they ship with the plugin repo, not with DMS.")
pending_prs, official_pending = pending_plugin_prs()
for checkout, files in official_pending:
info(f"{checkout.name}: {len(files)} translation files to commit in dms-plugins")
if open_prs:
for checkout, files in pending_prs:
open_plugin_pr(checkout, files)
elif pending_prs:
for checkout, files in pending_prs:
info(f"{checkout.name} has {len(files)} uncommitted translation files")
info("Re-run with --pr to push them to your fork and open the PRs upstream.")
pending_seed = sum(len(values) for values in plugin_seed.values())
if pending_seed and not seed:
info(f"{pending_seed} plugin-authored translations are missing from POEditor across {len(plugin_seed)} languages.")
info("Re-run with --seed to adopt them (one throttled upload per language).")
if pending_seed and seed:
seeded = seed_plugin_translations(api_token, project_id, plugin_seed)
if seeded:
info("Plugin-authored translations adopted into POEditor: " + ", ".join(f"{lang} +{count}" for lang, count in sorted(seeded.items())))
elif command == "pr":
pending_prs, official_pending = pending_plugin_prs()
for checkout, files in official_pending:
info(f"{checkout.name}: {len(files)} translation files to commit in dms-plugins")
if not pending_prs:
info("No plugin checkout has uncommitted translations")
sys.exit(0)
for checkout, files in pending_prs:
open_plugin_pr(checkout, files)
elif command == "local":
info("Updating en.json locally (no POEditor sync)")
+15 -2
View File
@@ -18,6 +18,8 @@ from pathlib import Path
ROOT_DIR = Path(__file__).parent.parent
EXTERNAL_PLUGIN_PREFIX = "dms-plugins-external/"
# Intentional pairs that survive normalization on purpose.
ALLOWED = [
{"PIN", "Pin"}, # WPS PIN acronym vs the verb "pin"
@@ -33,8 +35,19 @@ def normalize(term):
return t + "..." if ellipsis else t
def owned_occurrences(data):
return [occ for occ in data["occurrences"]
if not occ["file"].startswith(EXTERNAL_PLUGIN_PREFIX)]
def main():
translations = extract_qstr_strings(ROOT_DIR)
extracted = extract_qstr_strings(ROOT_DIR)
translations = {}
for term, data in extracted.items():
occurrences = owned_occurrences(data)
if occurrences:
translations[term] = occurrences
groups = defaultdict(set)
for term in translations:
groups[normalize(term)].add(term)
@@ -59,7 +72,7 @@ def main():
for variants in sorted(failures):
print(f" {variants}", file=sys.stderr)
for v in variants:
for occ in translations[v]["occurrences"][:3]:
for occ in translations[v][:3]:
print(f" {v!r}: {occ['file']}:{occ['line']}", file=sys.stderr)
return 1
File diff suppressed because it is too large Load Diff
@@ -203,12 +203,14 @@ def main():
en_json_path = translations_dir / 'en.json'
with open(en_json_path, 'w', encoding='utf-8') as f:
json.dump(poeditor_data, f, indent=2, ensure_ascii=False)
f.write('\n')
print(f"Created source language file: {en_json_path}")
template_data = create_template_json(translations)
template_json_path = translations_dir / 'template.json'
with open(template_json_path, 'w', encoding='utf-8') as f:
json.dump(template_data, f, indent=2, ensure_ascii=False)
f.write('\n')
print(f"Created template file: {template_json_path}")
print("\nSummary:")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -8225,18 +8225,21 @@
"tabIndex": 20,
"category": "System Updater",
"keywords": [
"alongside",
"apply",
"aur",
"enabled",
"include",
"packages",
"paru",
"running",
"shelly",
"system",
"updater",
"updates",
"upgrade",
"yay"
],
"description": "Run paru/yay with AUR enabled when "
"description": "Apply AUR updates alongside system updates when running "
},
{
"section": "systemUpdaterFlatpak",
File diff suppressed because it is too large Load Diff