dsh-wsl-desktop 0.7.5 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +8 -0
- package/README.md +5 -0
- package/lib/client.js +452 -4
- package/lib/wsl/confinement.js +20 -7
- package/lib/wsl/dsh-wsl-confine.sh +40 -13
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -86,6 +86,14 @@ A few details you will run into:
|
|
|
86
86
|
this plugin does not override it.
|
|
87
87
|
- Inside an open WSL workspace, the file list and the terminal are the distro's. PowerShell in Windows
|
|
88
88
|
workspaces is unaffected.
|
|
89
|
+
- **The W button has its own shortcut, and its hint follows a rebind.** The Windows desktop default is
|
|
90
|
+
`Ctrl+Alt+W` (the web profile declares none: the sidebar page-close command's web binding is the
|
|
91
|
+
same combination, and registration validates every profile's defaults, so declaring it would refuse
|
|
92
|
+
the command on the desktop too); hovering or focusing W shows the action name and keycaps the same way the
|
|
93
|
+
adjacent "+" does. The command is registered in Keyboard shortcuts, so rebinding or clearing it
|
|
94
|
+
updates the keycaps and `aria-keyshortcuts` immediately. When another command already owns the
|
|
95
|
+
default combination and registration is refused, the W button still works and merely omits the
|
|
96
|
+
keycaps; the shortcut service itself is a desktop dependency present since 0.1.7.
|
|
89
97
|
|
|
90
98
|
## What will surprise you
|
|
91
99
|
|
package/README.md
CHANGED
|
@@ -72,6 +72,11 @@ plugin_manager install_bundle dsh-wsl-desktop@<version>
|
|
|
72
72
|
表示网络/子系统路径的写法,形如 `\\wsl.localhost\<发行版>\home\...`。
|
|
73
73
|
- 自带的「+」保持部署原本的行为(桌面上是 Electron 的目录选择器),本插件不改写它。
|
|
74
74
|
- 已经打开的 WSL 工作区里,文件列表和终端都是发行版内的。Windows 工作区里的 PowerShell 不受影响。
|
|
75
|
+
- **W 按钮有自己的快捷键,提示会跟着改绑走。** Windows 桌面的默认是 `Ctrl+Alt+W`(Web 端不声明默认:
|
|
76
|
+
侧栏页面关闭(`page.close`)的 Web 组合也是它,而快捷键注册会校验所有平台的默认值,声明它会让命令在桌面端也注册失败);
|
|
77
|
+
悬停或聚焦 W 时,提示会和左边的「+」一样显示动作名与键帽。命令登记在「快捷键」设置里,
|
|
78
|
+
改绑或清除后键帽与 `aria-keyshortcuts` 随即更新。默认组合被别的命令占用、导致注册被拒时,
|
|
79
|
+
W 按钮照常工作,只是不显示键帽;快捷键服务本身是 0.1.7 起桌面自带的依赖。
|
|
75
80
|
|
|
76
81
|
## 会意外的地方
|
|
77
82
|
|
package/lib/client.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Adds one control beside the shipped "add workspace" button in the sidebar
|
|
5
5
|
* section header: a `W` button opening this plugin's WSL workspace picker.
|
|
6
|
+
* The button carries the same kind of shortcut hint the shipped trigger does:
|
|
7
|
+
* its tooltip shows the effective keys from the shell's shortcut catalog, and
|
|
8
|
+
* the registered command opens the picker from anywhere the page owns the
|
|
9
|
+
* keyboard.
|
|
6
10
|
*
|
|
7
11
|
* The shipped button keeps its own behaviour: this half registers no slot
|
|
8
12
|
* occupant, because `sidebar.workspaces.directoryFlow` is `kind: 'single'` and
|
|
@@ -71,6 +75,28 @@ window.__ModuleLoader__.load({
|
|
|
71
75
|
/** Sprite symbol id shape, as `iconSymbolId` composes it: `dsw-icon-<glyph>-<weight>`. */
|
|
72
76
|
const SPRITE_ICON_ID = /^#?dsw-icon-(.+)-(?:regular|medium)$/
|
|
73
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Stable command identity of the W button's keyboard action.
|
|
80
|
+
*
|
|
81
|
+
* The id is the plugin's public shortcut identity: it travels through the
|
|
82
|
+
* shell's command catalog, appears in the Keyboard shortcuts reference, and
|
|
83
|
+
* survives user rebinds. Renaming it orphans every saved override, so it
|
|
84
|
+
* names the action rather than the button's ever-changing presentation.
|
|
85
|
+
*/
|
|
86
|
+
const SHORTCUT_ID = 'wsl.workspace.open'
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The add-workspace trigger's own tooltip delay, mirrored so the two
|
|
90
|
+
* controls beside each other reveal their shortcut hints together.
|
|
91
|
+
*/
|
|
92
|
+
const TOOLTIP_HOVER_DELAY_MS = 500
|
|
93
|
+
|
|
94
|
+
/** Gap between the W button and its shortcut tooltip, in CSS pixels. */
|
|
95
|
+
const TOOLTIP_GAP = 8
|
|
96
|
+
|
|
97
|
+
/** Minimum distance the shortcut tooltip keeps from the viewport edges. */
|
|
98
|
+
const TOOLTIP_MARGIN = 12
|
|
99
|
+
|
|
74
100
|
/**
|
|
75
101
|
* The row glyphs whose artwork is FILL-only, as sprite names.
|
|
76
102
|
*
|
|
@@ -1518,7 +1544,15 @@ window.__ModuleLoader__.load({
|
|
|
1518
1544
|
|
|
1519
1545
|
/** The injected services the picker and its trigger read. */
|
|
1520
1546
|
// ── 5. the companion button and workspace rows ─────────────────────────────
|
|
1521
|
-
const companion = {
|
|
1547
|
+
const companion = {
|
|
1548
|
+
button: null,
|
|
1549
|
+
fallback: null,
|
|
1550
|
+
hideHint: null,
|
|
1551
|
+
trigger: null,
|
|
1552
|
+
observer: null,
|
|
1553
|
+
scheduled: false,
|
|
1554
|
+
disposed: false,
|
|
1555
|
+
}
|
|
1522
1556
|
|
|
1523
1557
|
/** Whether the missing-workspace-source warning was already printed. */
|
|
1524
1558
|
let warnedWorkspaceSource = false
|
|
@@ -1876,6 +1910,10 @@ window.__ModuleLoader__.load({
|
|
|
1876
1910
|
companion.trigger = trigger
|
|
1877
1911
|
}
|
|
1878
1912
|
if (trigger === null) {
|
|
1913
|
+
// React took the anchor away, so the companion leaves too. The tooltip
|
|
1914
|
+
// is a fixed portal with no pointer to leave; withdraw it explicitly or
|
|
1915
|
+
// it outlives the control it describes.
|
|
1916
|
+
companion.hideHint?.()
|
|
1879
1917
|
if (button.isConnected) button.remove()
|
|
1880
1918
|
return
|
|
1881
1919
|
}
|
|
@@ -1896,7 +1934,13 @@ window.__ModuleLoader__.load({
|
|
|
1896
1934
|
const box = button.getBoundingClientRect()
|
|
1897
1935
|
const fits = row === undefined || box.right <= row.right + 1
|
|
1898
1936
|
const triggerVisible = getComputedStyle(trigger).visibility !== 'hidden'
|
|
1899
|
-
|
|
1937
|
+
const shown = fits && triggerVisible
|
|
1938
|
+
button.style.display = shown ? '' : 'none'
|
|
1939
|
+
// The shipped cluster hides itself (inline search, narrow rail) by
|
|
1940
|
+
// visibility and the companion follows via display; a visible tooltip must
|
|
1941
|
+
// follow too, because no pointerleave fires for a control that left the
|
|
1942
|
+
// hit-test tree.
|
|
1943
|
+
if (!shown) companion.hideHint?.()
|
|
1900
1944
|
}
|
|
1901
1945
|
|
|
1902
1946
|
/**
|
|
@@ -1911,6 +1955,362 @@ window.__ModuleLoader__.load({
|
|
|
1911
1955
|
return document.body ?? document.documentElement
|
|
1912
1956
|
}
|
|
1913
1957
|
|
|
1958
|
+
/**
|
|
1959
|
+
* The effective catalog row for the W shortcut, or null when the shell has
|
|
1960
|
+
* no shortcut service or has not published the command yet.
|
|
1961
|
+
*
|
|
1962
|
+
* The row is the SINGLE source of the hint: `keys` are the platform-formatted
|
|
1963
|
+
* labels the shortcut service presents, and `aria` is the same binding in
|
|
1964
|
+
* `aria-keyshortcuts` spelling. A rebind replaces the row, so the tooltip
|
|
1965
|
+
* follows the user's setting instead of the default first seen.
|
|
1966
|
+
* @param {object} ctx - the client plugin context.
|
|
1967
|
+
* @returns {object | null} the row for `SHORTCUT_ID`, or null.
|
|
1968
|
+
*/
|
|
1969
|
+
function shortcutRowOf(ctx) {
|
|
1970
|
+
const catalog = ctx.shortcuts?.catalog
|
|
1971
|
+
if (typeof catalog?.getSnapshot !== 'function') return null
|
|
1972
|
+
const rows = catalog.getSnapshot()
|
|
1973
|
+
if (!Array.isArray(rows)) return null
|
|
1974
|
+
return rows.find((row) => row.id === SHORTCUT_ID) ?? null
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* Publish or withdraw the accessible shortcut claim on one control.
|
|
1979
|
+
* @param {HTMLElement | null} node - the control, or null when it is absent.
|
|
1980
|
+
* @param {object | null} row - the effective catalog row.
|
|
1981
|
+
*/
|
|
1982
|
+
function setKeyshortcuts(node, row) {
|
|
1983
|
+
if (node === null) return
|
|
1984
|
+
if (typeof row?.aria === 'string' && row.aria !== '') node.setAttribute('aria-keyshortcuts', row.aria)
|
|
1985
|
+
else node.removeAttribute('aria-keyshortcuts')
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/** Tooltip bubble styling, mirrored from the shipped tooltip's semantic tokens. */
|
|
1989
|
+
const SHORTCUT_TOOLTIP_STYLE = {
|
|
1990
|
+
position: 'fixed',
|
|
1991
|
+
zIndex: '1100',
|
|
1992
|
+
display: 'none',
|
|
1993
|
+
alignItems: 'center',
|
|
1994
|
+
gap: '8px',
|
|
1995
|
+
width: 'max-content',
|
|
1996
|
+
maxWidth: 'min(50vw, calc(100vw - 24px))',
|
|
1997
|
+
boxSizing: 'border-box',
|
|
1998
|
+
padding: '3px 7px',
|
|
1999
|
+
borderRadius: 'var(--dsw-radius-sm, 8px)',
|
|
2000
|
+
background: 'var(--dsw-alias-tooltip-bg, #3a3a3a)',
|
|
2001
|
+
color: 'var(--dsw-static-neutral-bluish-00, #ffffff)',
|
|
2002
|
+
fontSize: '13px',
|
|
2003
|
+
lineHeight: '20px',
|
|
2004
|
+
whiteSpace: 'pre-line',
|
|
2005
|
+
overflowWrap: 'break-word',
|
|
2006
|
+
pointerEvents: 'none',
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/** The keycap strip's layout inside a tooltip. */
|
|
2010
|
+
const SHORTCUT_KEYS_STYLE = {
|
|
2011
|
+
display: 'inline-flex',
|
|
2012
|
+
flex: 'none',
|
|
2013
|
+
alignItems: 'center',
|
|
2014
|
+
gap: '2px',
|
|
2015
|
+
color: 'inherit',
|
|
2016
|
+
fontSize: '11px',
|
|
2017
|
+
lineHeight: '14px',
|
|
2018
|
+
whiteSpace: 'nowrap',
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/** One keycap: the shipped tooltip's 16px badge. */
|
|
2022
|
+
const SHORTCUT_KEY_STYLE = {
|
|
2023
|
+
boxSizing: 'border-box',
|
|
2024
|
+
display: 'inline-flex',
|
|
2025
|
+
alignItems: 'center',
|
|
2026
|
+
justifyContent: 'center',
|
|
2027
|
+
font: 'inherit',
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/**
|
|
2031
|
+
* One shortcut tooltip attached to a plain DOM control.
|
|
2032
|
+
*
|
|
2033
|
+
* The shipped "add workspace" button gets this from the React Tooltip in
|
|
2034
|
+
* `ui-primitives`; this half is a hand-written factory with no module graph,
|
|
2035
|
+
* and a feature plugin may not reach into another feature's values, so the
|
|
2036
|
+
* same presentation is rebuilt here on the shared semantic tokens. The
|
|
2037
|
+
* bubble is portaled to the body and positioned fixed, so the sidebar's
|
|
2038
|
+
* clipping and stacking contexts cannot hide it.
|
|
2039
|
+
* @param {HTMLElement} anchor - the control the tooltip belongs to.
|
|
2040
|
+
* @param {string} label - the action name shown before the keycaps.
|
|
2041
|
+
* @returns {{ setKeys: (keys: readonly string[]) => void, dismiss: () => void, dispose: () => void }}
|
|
2042
|
+
* the key publisher, the layout-driven dismissal, and the disposer.
|
|
2043
|
+
*/
|
|
2044
|
+
function createShortcutHint(anchor, label) {
|
|
2045
|
+
let bubble = null
|
|
2046
|
+
let keys = []
|
|
2047
|
+
let showTimer = null
|
|
2048
|
+
let visible = false
|
|
2049
|
+
// The shipped Tooltip keeps hover and focus as INDEPENDENT triggers and
|
|
2050
|
+
// withdraws the bubble only after BOTH clear: hovering away from a focused
|
|
2051
|
+
// anchor must not drop it, and blurring while the pointer is still over
|
|
2052
|
+
// the anchor must not drop it either.
|
|
2053
|
+
let hovered = false
|
|
2054
|
+
let focused = false
|
|
2055
|
+
|
|
2056
|
+
function clearShowTimer() {
|
|
2057
|
+
if (showTimer === null) return
|
|
2058
|
+
clearTimeout(showTimer)
|
|
2059
|
+
showTimer = null
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
/**
|
|
2063
|
+
* Render the current keycap labels into the bubble, or hide the strip.
|
|
2064
|
+
*
|
|
2065
|
+
* Windows presents a combination as one plus-separated run in a single
|
|
2066
|
+
* pill; a modifier set without a separator (macOS symbols) gets one cap
|
|
2067
|
+
* per key. Both shapes are built from the catalog's labels, never from a
|
|
2068
|
+
* key spelling written in this file.
|
|
2069
|
+
*/
|
|
2070
|
+
function renderKeys() {
|
|
2071
|
+
if (bubble === null) return
|
|
2072
|
+
const strip = bubble.querySelector('[data-wsl-tooltip-keys]')
|
|
2073
|
+
if (strip === null) return
|
|
2074
|
+
strip.replaceChildren()
|
|
2075
|
+
if (keys.length === 0) {
|
|
2076
|
+
strip.style.display = 'none'
|
|
2077
|
+
bubble.style.paddingInlineEnd = '7px'
|
|
2078
|
+
return
|
|
2079
|
+
}
|
|
2080
|
+
strip.style.display = 'inline-flex'
|
|
2081
|
+
bubble.style.paddingInlineEnd = '5px'
|
|
2082
|
+
const joined = keys.includes('+')
|
|
2083
|
+
strip.style.background = joined
|
|
2084
|
+
? 'var(--dsw-alias-tooltip-key-bg, rgba(255,255,255,0.18))'
|
|
2085
|
+
: 'transparent'
|
|
2086
|
+
strip.style.height = joined ? '16px' : ''
|
|
2087
|
+
strip.style.padding = joined ? '0 4px' : '0'
|
|
2088
|
+
strip.style.borderRadius = joined ? '4px' : ''
|
|
2089
|
+
for (const token of keys) {
|
|
2090
|
+
const key = document.createElement('kbd')
|
|
2091
|
+
key.textContent = token
|
|
2092
|
+
Object.assign(key.style, SHORTCUT_KEY_STYLE)
|
|
2093
|
+
if (!joined) {
|
|
2094
|
+
key.style.minWidth = '16px'
|
|
2095
|
+
key.style.height = '16px'
|
|
2096
|
+
key.style.padding = '0 2px'
|
|
2097
|
+
key.style.borderRadius = '4px'
|
|
2098
|
+
key.style.background = 'var(--dsw-alias-tooltip-key-bg, rgba(255,255,255,0.18))'
|
|
2099
|
+
}
|
|
2100
|
+
strip.append(key)
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
/** Build the bubble once: a label span and a keycap strip. */
|
|
2105
|
+
function ensureBubble() {
|
|
2106
|
+
if (bubble !== null) return bubble
|
|
2107
|
+
bubble = el('span', SHORTCUT_TOOLTIP_STYLE)
|
|
2108
|
+
bubble.setAttribute('role', 'tooltip')
|
|
2109
|
+
bubble.setAttribute('aria-hidden', 'true')
|
|
2110
|
+
bubble.setAttribute('data-wsl-shortcut-tooltip', '')
|
|
2111
|
+
const text = el('span', { minWidth: '0' }, label)
|
|
2112
|
+
text.setAttribute('data-wsl-tooltip-label', '')
|
|
2113
|
+
const strip = el('span', SHORTCUT_KEYS_STYLE)
|
|
2114
|
+
strip.setAttribute('data-wsl-tooltip-keys', '')
|
|
2115
|
+
bubble.append(text, strip)
|
|
2116
|
+
renderKeys()
|
|
2117
|
+
return bubble
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/** Place the bubble below the anchor, clamped to the viewport. */
|
|
2121
|
+
function position() {
|
|
2122
|
+
if (bubble === null) return
|
|
2123
|
+
const anchorBox = anchor.getBoundingClientRect()
|
|
2124
|
+
// A media query or an ancestor class can collapse the header on resize
|
|
2125
|
+
// without any DOM mutation, so no layout pass reaches the hint. A zero
|
|
2126
|
+
// box means the tooltip has no anchor left to describe: withdraw it
|
|
2127
|
+
// instead of placing it from (0, 0).
|
|
2128
|
+
if (anchorBox.width <= 0 && anchorBox.height <= 0) {
|
|
2129
|
+
hide()
|
|
2130
|
+
return
|
|
2131
|
+
}
|
|
2132
|
+
const bubbleBox = bubble.getBoundingClientRect()
|
|
2133
|
+
const centered = anchorBox.left + anchorBox.width / 2 - bubbleBox.width / 2
|
|
2134
|
+
const left = Math.min(
|
|
2135
|
+
Math.max(TOOLTIP_MARGIN, centered),
|
|
2136
|
+
Math.max(TOOLTIP_MARGIN, window.innerWidth - bubbleBox.width - TOOLTIP_MARGIN),
|
|
2137
|
+
)
|
|
2138
|
+
let top = anchorBox.bottom + TOOLTIP_GAP
|
|
2139
|
+
// The sidebar header sits near the top edge, but a short window or a
|
|
2140
|
+
// moved trigger still has to get a readable bubble: flip above the
|
|
2141
|
+
// anchor when the space below cannot hold it.
|
|
2142
|
+
if (top + bubbleBox.height > window.innerHeight - TOOLTIP_MARGIN) {
|
|
2143
|
+
top = Math.max(TOOLTIP_MARGIN, anchorBox.top - bubbleBox.height - TOOLTIP_GAP)
|
|
2144
|
+
}
|
|
2145
|
+
bubble.style.left = `${left}px`
|
|
2146
|
+
bubble.style.top = `${top}px`
|
|
2147
|
+
bubble.style.visibility = 'visible'
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function show() {
|
|
2151
|
+
if (!anchor.isConnected || anchor.style.display === 'none') return
|
|
2152
|
+
// Closing the picker refocuses its opener. When that opener already sits
|
|
2153
|
+
// in a hidden subtree its box is zero; placing a fixed bubble from that
|
|
2154
|
+
// box would paint it at the viewport origin.
|
|
2155
|
+
const anchorBox = anchor.getBoundingClientRect()
|
|
2156
|
+
if (anchorBox.width <= 0 && anchorBox.height <= 0) return
|
|
2157
|
+
const node = ensureBubble()
|
|
2158
|
+
if (!node.isConnected) hostElement().append(node)
|
|
2159
|
+
visible = true
|
|
2160
|
+
node.style.display = 'inline-flex'
|
|
2161
|
+
node.style.visibility = 'hidden'
|
|
2162
|
+
position()
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
function hide() {
|
|
2166
|
+
clearShowTimer()
|
|
2167
|
+
visible = false
|
|
2168
|
+
if (bubble !== null) bubble.style.display = 'none'
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/**
|
|
2172
|
+
* Withdraw the bubble AND clear both triggers.
|
|
2173
|
+
*
|
|
2174
|
+
* The layout pass calls this when the anchor leaves the layout, mirroring
|
|
2175
|
+
* the shipped Tooltip's disabled effect: without the reset, a hover that
|
|
2176
|
+
* was live when the header hid survives the withdrawal, and a later
|
|
2177
|
+
* focus/blur can keep a bubble on screen with no pointer over the anchor.
|
|
2178
|
+
*/
|
|
2179
|
+
function dismiss() {
|
|
2180
|
+
hovered = false
|
|
2181
|
+
focused = false
|
|
2182
|
+
hide()
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
function reveal(delay) {
|
|
2186
|
+
clearShowTimer()
|
|
2187
|
+
if (delay <= 0) {
|
|
2188
|
+
show()
|
|
2189
|
+
return
|
|
2190
|
+
}
|
|
2191
|
+
showTimer = setTimeout(() => {
|
|
2192
|
+
showTimer = null
|
|
2193
|
+
show()
|
|
2194
|
+
}, delay)
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
const onPointerEnter = (event) => {
|
|
2198
|
+
// Touch has no hover state; tapping still invokes the button.
|
|
2199
|
+
if (event.pointerType === 'touch') return
|
|
2200
|
+
hovered = true
|
|
2201
|
+
reveal(TOOLTIP_HOVER_DELAY_MS)
|
|
2202
|
+
}
|
|
2203
|
+
const onPointerLeave = () => {
|
|
2204
|
+
hovered = false
|
|
2205
|
+
// A pending hover reveal is cancelled even when focus holds the bubble
|
|
2206
|
+
// open; otherwise it would reappear after the pointer has left.
|
|
2207
|
+
clearShowTimer()
|
|
2208
|
+
if (!focused) hide()
|
|
2209
|
+
}
|
|
2210
|
+
const onFocus = () => {
|
|
2211
|
+
// The shared input-modality attribute is how the shell publishes
|
|
2212
|
+
// pointer-acquired focus; a picker closing back onto its trigger must
|
|
2213
|
+
// not flash the shortcut bubble after a click.
|
|
2214
|
+
if (document.documentElement.getAttribute('data-input-modality') === 'pointer') return
|
|
2215
|
+
focused = true
|
|
2216
|
+
reveal(0)
|
|
2217
|
+
}
|
|
2218
|
+
const onBlur = () => {
|
|
2219
|
+
focused = false
|
|
2220
|
+
clearShowTimer()
|
|
2221
|
+
if (!hovered) hide()
|
|
2222
|
+
}
|
|
2223
|
+
const onClick = () => {
|
|
2224
|
+
// Activating the button dismisses the bubble even though the pointer
|
|
2225
|
+
// stays over it; the shipped tooltip clears its focus trigger here too.
|
|
2226
|
+
focused = false
|
|
2227
|
+
hide()
|
|
2228
|
+
}
|
|
2229
|
+
const onResize = () => { if (visible) position() }
|
|
2230
|
+
anchor.addEventListener('pointerenter', onPointerEnter)
|
|
2231
|
+
anchor.addEventListener('pointerleave', onPointerLeave)
|
|
2232
|
+
anchor.addEventListener('focus', onFocus)
|
|
2233
|
+
anchor.addEventListener('blur', onBlur)
|
|
2234
|
+
anchor.addEventListener('click', onClick)
|
|
2235
|
+
window.addEventListener('resize', onResize)
|
|
2236
|
+
|
|
2237
|
+
return {
|
|
2238
|
+
/**
|
|
2239
|
+
* Publish the effective keys to the (possibly not yet built) bubble.
|
|
2240
|
+
* @param {readonly string[]} nextKeys - the catalog's presented labels.
|
|
2241
|
+
*/
|
|
2242
|
+
setKeys(nextKeys) {
|
|
2243
|
+
const normalized = Array.isArray(nextKeys) ? nextKeys.map(String) : []
|
|
2244
|
+
if (normalized.join('\u0000') === keys.join('\u0000')) return
|
|
2245
|
+
keys = normalized
|
|
2246
|
+
if (bubble === null) return
|
|
2247
|
+
renderKeys()
|
|
2248
|
+
if (visible) position()
|
|
2249
|
+
},
|
|
2250
|
+
/** Withdraw the bubble and clear both triggers when its anchor leaves the layout. */
|
|
2251
|
+
dismiss,
|
|
2252
|
+
/** Release the bubble and every listener it installed. */
|
|
2253
|
+
dispose() {
|
|
2254
|
+
clearShowTimer()
|
|
2255
|
+
visible = false
|
|
2256
|
+
hovered = false
|
|
2257
|
+
focused = false
|
|
2258
|
+
anchor.removeEventListener('pointerenter', onPointerEnter)
|
|
2259
|
+
anchor.removeEventListener('pointerleave', onPointerLeave)
|
|
2260
|
+
anchor.removeEventListener('focus', onFocus)
|
|
2261
|
+
anchor.removeEventListener('blur', onBlur)
|
|
2262
|
+
anchor.removeEventListener('click', onClick)
|
|
2263
|
+
window.removeEventListener('resize', onResize)
|
|
2264
|
+
if (bubble !== null) {
|
|
2265
|
+
bubble.remove()
|
|
2266
|
+
bubble = null
|
|
2267
|
+
}
|
|
2268
|
+
keys = []
|
|
2269
|
+
},
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
/**
|
|
2274
|
+
* Register the W button's keyboard command, tolerating a host without the
|
|
2275
|
+
* shortcut service or a catalog that refuses the default.
|
|
2276
|
+
*
|
|
2277
|
+
* Registration is deliberately outside the button's click path: the command
|
|
2278
|
+
* resolves to the same `openPicker` the click calls, so the two entries cannot
|
|
2279
|
+
* drift. A refused registration (another command already owns the same
|
|
2280
|
+
* default on one of this command's profiles) is logged and leaves the
|
|
2281
|
+
* button usable; the hint then carries the label alone.
|
|
2282
|
+
* @param {object} ctx - the client plugin context.
|
|
2283
|
+
* @returns {() => void} the registration's disposer, or a no-op.
|
|
2284
|
+
*/
|
|
2285
|
+
function installShortcut(ctx) {
|
|
2286
|
+
try {
|
|
2287
|
+
// The access itself is inside the guard: a context whose service getter
|
|
2288
|
+
// throws must degrade the hint, not abort the install that owns W.
|
|
2289
|
+
const shortcuts = ctx.shortcuts
|
|
2290
|
+
if (typeof shortcuts?.register !== 'function') return () => {}
|
|
2291
|
+
return shortcuts.register({
|
|
2292
|
+
id: SHORTCUT_ID,
|
|
2293
|
+
label: () => BUTTON_LABEL,
|
|
2294
|
+
aliases: ['wsl', 'wsl workspace', 'open wsl workspace'],
|
|
2295
|
+
defaults: {
|
|
2296
|
+
// Desktop only. On the Web profile the shell's page.close command
|
|
2297
|
+
// already owns KeyW+primary+alt, and the shortcut registry
|
|
2298
|
+
// validates EVERY profile's defaults at registration:
|
|
2299
|
+
// declaring the Web default here makes this command throw even on
|
|
2300
|
+
// a desktop runtime, so the hint loses its keys and the key does
|
|
2301
|
+
// nothing. Web users can bind the command themselves.
|
|
2302
|
+
'desktop:windows': { code: 'KeyW', modifiers: ['primary', 'alt'] },
|
|
2303
|
+
},
|
|
2304
|
+
regions: ['page', 'editable'],
|
|
2305
|
+
modals: ['sidebar'],
|
|
2306
|
+
resolve: () => ({ status: 'handled', run: () => openPicker(ctx) }),
|
|
2307
|
+
})
|
|
2308
|
+
} catch (error) {
|
|
2309
|
+
console.warn('dsh-wsl-desktop: W 快捷键注册失败,W 按钮仍然可用', error)
|
|
2310
|
+
return () => {}
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
1914
2314
|
/**
|
|
1915
2315
|
* Install the companion button and keep it attached.
|
|
1916
2316
|
* @param {object} ctx - the client plugin context.
|
|
@@ -1923,19 +2323,22 @@ window.__ModuleLoader__.load({
|
|
|
1923
2323
|
button.setAttribute('aria-label', BUTTON_LABEL)
|
|
1924
2324
|
button.setAttribute('aria-haspopup', 'dialog')
|
|
1925
2325
|
button.setAttribute('aria-expanded', 'false')
|
|
1926
|
-
button.title = BUTTON_LABEL
|
|
1927
2326
|
button.append(el('span', {
|
|
1928
2327
|
fontWeight: '600',
|
|
1929
2328
|
fontSize: '13px',
|
|
1930
2329
|
lineHeight: '1',
|
|
1931
2330
|
letterSpacing: '0.02em',
|
|
1932
2331
|
}, 'W'))
|
|
2332
|
+
// The hint replaces the native title: it carries the SAME action name plus
|
|
2333
|
+
// the effective keycaps, and a title would show a second, stale tooltip.
|
|
2334
|
+
const hint = createShortcutHint(button, BUTTON_LABEL)
|
|
1933
2335
|
button.addEventListener('click', (event) => {
|
|
1934
2336
|
event.preventDefault()
|
|
1935
2337
|
event.stopPropagation()
|
|
1936
2338
|
openPicker(ctx)
|
|
1937
2339
|
})
|
|
1938
2340
|
companion.button = button
|
|
2341
|
+
companion.hideHint = hint.dismiss
|
|
1939
2342
|
|
|
1940
2343
|
/** The degraded entry shown when the sidebar anchor is missing. */
|
|
1941
2344
|
let fallbackEntry = null
|
|
@@ -1994,7 +2397,45 @@ window.__ModuleLoader__.load({
|
|
|
1994
2397
|
hostElement().append(entry)
|
|
1995
2398
|
fallbackEntry = entry
|
|
1996
2399
|
companion.fallback = open
|
|
2400
|
+
// The fallback is the W button's only visible entry in this phase, so
|
|
2401
|
+
// the shortcut claim belongs on it too; the visual bubble stays with the
|
|
2402
|
+
// real button to keep the failure path minimal.
|
|
2403
|
+
applyShortcut()
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/**
|
|
2407
|
+
* Publish the effective shortcut row to the button, hint, and fallback.
|
|
2408
|
+
*
|
|
2409
|
+
* Called once at install and again for every catalog change (a rebind, a
|
|
2410
|
+
* clear, another command's registration). The row is read from the live
|
|
2411
|
+
* catalog rather than remembered, so a rebind cannot leave the hint
|
|
2412
|
+
* claiming keys the command no longer owns.
|
|
2413
|
+
*/
|
|
2414
|
+
function applyShortcut() {
|
|
2415
|
+
let row = null
|
|
2416
|
+
try {
|
|
2417
|
+
row = shortcutRowOf(ctx)
|
|
2418
|
+
} catch (error) {
|
|
2419
|
+
// A throwing catalog is a shell fault, not a reason to lose the W
|
|
2420
|
+
// button: the hint is peripheral and degrades to its label.
|
|
2421
|
+
console.warn('dsh-wsl-desktop: W 快捷键目录读取失败,W 按钮仍然可用', error)
|
|
2422
|
+
}
|
|
2423
|
+
setKeyshortcuts(button, row)
|
|
2424
|
+
hint.setKeys(row?.keys ?? [])
|
|
2425
|
+
setKeyshortcuts(fallbackEntry?.querySelector('[data-wsl-fallback-entry]') ?? null, row)
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
const disposeShortcut = installShortcut(ctx)
|
|
2429
|
+
let disposeCatalog = null
|
|
2430
|
+
try {
|
|
2431
|
+
const catalog = ctx.shortcuts?.catalog
|
|
2432
|
+
disposeCatalog = typeof catalog?.subscribe === 'function'
|
|
2433
|
+
? catalog.subscribe(applyShortcut)
|
|
2434
|
+
: null
|
|
2435
|
+
} catch (error) {
|
|
2436
|
+
console.warn('dsh-wsl-desktop: W 快捷键目录订阅失败,W 按钮仍然可用', error)
|
|
1997
2437
|
}
|
|
2438
|
+
applyShortcut()
|
|
1998
2439
|
|
|
1999
2440
|
// `sync` is idempotent and never removes the node it manages, so its own
|
|
2000
2441
|
// work cannot re-trigger the observer it runs from.
|
|
@@ -2049,6 +2490,12 @@ window.__ModuleLoader__.load({
|
|
|
2049
2490
|
return () => {
|
|
2050
2491
|
clearTimeout(warning)
|
|
2051
2492
|
removeFallback()
|
|
2493
|
+
// Unsubscribe BEFORE the command leaves the catalog: removing it makes
|
|
2494
|
+
// the service publish a fresh snapshot, and a listener still attached
|
|
2495
|
+
// would rebuild the bubble this teardown is about to delete.
|
|
2496
|
+
if (disposeCatalog !== null) disposeCatalog()
|
|
2497
|
+
disposeShortcut()
|
|
2498
|
+
hint.dispose()
|
|
2052
2499
|
observer.disconnect()
|
|
2053
2500
|
unsubscribe?.()
|
|
2054
2501
|
// disconnect() does not cancel a callback already handed to
|
|
@@ -2066,6 +2513,7 @@ window.__ModuleLoader__.load({
|
|
|
2066
2513
|
}
|
|
2067
2514
|
button.remove()
|
|
2068
2515
|
companion.button = null
|
|
2516
|
+
companion.hideHint = null
|
|
2069
2517
|
companion.trigger = null
|
|
2070
2518
|
companion.observer = null
|
|
2071
2519
|
teardown?.()
|
|
@@ -2078,7 +2526,7 @@ window.__ModuleLoader__.load({
|
|
|
2078
2526
|
// `remote.session` is the namespace the create request travels through
|
|
2079
2527
|
// (see `createBoundSession`); the harness's own client plugins inject it
|
|
2080
2528
|
// the same way.
|
|
2081
|
-
inject: ['workspaces', 'uiWorkspace', 'sessions', 'remote', 'remote.session'],
|
|
2529
|
+
inject: ['workspaces', 'uiWorkspace', 'sessions', 'remote', 'remote.session', 'shortcuts'],
|
|
2082
2530
|
apply(ctx) {
|
|
2083
2531
|
ctx.effect(() => install(ctx), 'wsl-desktop: sidebar WSL workspace trigger')
|
|
2084
2532
|
},
|
package/lib/wsl/confinement.js
CHANGED
|
@@ -765,15 +765,28 @@ function postConditions(keep) {
|
|
|
765
765
|
// where the flag is the authority, i.e. on a target that is not storage. For
|
|
766
766
|
// a socket/FIFO/device, `[ -w ]` says nothing about the mount (see
|
|
767
767
|
// {@link KERNEL_SURFACES} and the module note), so the flag is what decides.
|
|
768
|
-
|
|
768
|
+
//
|
|
769
|
+
// WHICH test applies is decided by the inode TYPE from `stat -c %F`, NOT by
|
|
770
|
+
// `-f`/`-d`. Measured: after the fence binds the workspace onto itself, a
|
|
771
|
+
// unix socket bind-mounted UNDER the workspace answers `-f` TRUE, while the
|
|
772
|
+
// same socket answers `-f` FALSE before that bind — the shell test follows
|
|
773
|
+
// the mount the path resolves to, and the workspace bind changes what that
|
|
774
|
+
// is. Gating on `-f`/`-d` therefore sent the socket down the storage branch,
|
|
775
|
+
// where it failed with "is still writable": the issue #2 symptom this change
|
|
776
|
+
// exists to remove, reproduced by the fix itself. `stat` reports the inode
|
|
777
|
+
// class directly and is not fooled by the bind.
|
|
778
|
+
' kind=$(stat -c %F "$target" 2>/dev/null) || kind="";',
|
|
779
|
+
' case "$kind" in',
|
|
780
|
+
" 'regular file'|'regular empty file'|directory|'symbolic link')",
|
|
769
781
|
// Storage inode: `[ -w ]` decides. It already covers a covered record (the
|
|
770
782
|
// write would land on the reachable mount) and needs no flag test.
|
|
771
|
-
'
|
|
772
|
-
'
|
|
773
|
-
// Not storage: the mount flag decides, and it must be `ro` as
|
|
774
|
-
// (a mount's VFS options are a comma list; `ro`
|
|
775
|
-
|
|
776
|
-
'
|
|
783
|
+
' if [ -w "$target" ]; then fail "$target is still writable"; fi;;',
|
|
784
|
+
' *)',
|
|
785
|
+
// Not storage, or unreadable: the mount flag decides, and it must be `ro` as
|
|
786
|
+
// a comma member (a mount's VFS options are a comma list; `ro` has no fixed
|
|
787
|
+
// position).
|
|
788
|
+
' case ",$opts," in *,ro,*) ;; *) fail "$target is not read-only (mount options: $opts)" ;; esac;;',
|
|
789
|
+
' esac;',
|
|
777
790
|
// The trailing `true` keeps the pipeline itself green: this while is the
|
|
778
791
|
// LAST element of a pipeline, and anything that ends the body with a
|
|
779
792
|
// non-zero status would fail the whole pipeline under `set -o pipefail` and
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
# fence to the forged uid: root WRITE on the distribution's filesystem and on
|
|
37
37
|
# /mnt/c, with only the namespace and NO_NEW_PRIVS left standing.
|
|
38
38
|
#
|
|
39
|
-
# This script runs as root and calls
|
|
40
|
-
# tr, mount, findmnt, grep, mountpoint, setpriv, env, bash, unshare). sudo's
|
|
39
|
+
# This script runs as root and calls thirteen tools by bare name (getent, cut, sed,
|
|
40
|
+
# tr, mount, findmnt, grep, mountpoint, setpriv, env, bash, unshare, stat). sudo's
|
|
41
41
|
# env_reset does not save it: an exported PATH is replaced when sudoers sets
|
|
42
42
|
# secure_path, but a PATH handed over as a sudo command-line assignment still
|
|
43
43
|
# reaches us (measured on debian, debian-dev and arch), and the NOPASSWD grant is
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
# here, as the FIRST statement, rather than depending on a deployment option we
|
|
48
48
|
# cannot verify.
|
|
49
49
|
#
|
|
50
|
-
# The pin is unconditional and has NO fallback: the
|
|
50
|
+
# The pin is unconditional and has NO fallback: the thirteen tools must be reachable
|
|
51
51
|
# in these six directories. A distribution where one is not fails CLOSED - nothing
|
|
52
52
|
# runs: a missing getent leaves the caller record empty and the gate refuses it, a
|
|
53
53
|
# missing cut fails the pipeline under pipefail (127) before the fence, and the
|
|
@@ -170,6 +170,11 @@ fi
|
|
|
170
170
|
command -v unshare >/dev/null 2>&1 || { echo 'dsh-wsl-sandbox: setup failed: unshare not found' >&2; exit 97; }
|
|
171
171
|
command -v setpriv >/dev/null 2>&1 || { echo 'dsh-wsl-sandbox: setup failed: setpriv not found' >&2; exit 97; }
|
|
172
172
|
command -v findmnt >/dev/null 2>&1 || { echo 'dsh-wsl-sandbox: setup failed: findmnt not found' >&2; exit 97; }
|
|
173
|
+
# `stat` classifies the post-condition's target and is called INSIDE the fence,
|
|
174
|
+
# where a bare-name lookup failure would leave kind empty — that routes to the
|
|
175
|
+
# flag branch, which is fail-closed, but the report would then be a confusing
|
|
176
|
+
# "not read-only" rather than the real cause. Preflight it like the others.
|
|
177
|
+
command -v stat >/dev/null 2>&1 || { echo 'dsh-wsl-sandbox: setup failed: stat not found' >&2; exit 97; }
|
|
173
178
|
|
|
174
179
|
# The allow-list pattern is built HERE, from the validated parameters, with every
|
|
175
180
|
# entry escaped so a PATH stays data and never becomes regex syntax. Unescaped,
|
|
@@ -234,30 +239,52 @@ findmnt -rno TARGET | while IFS= read -r raw; do printf "%b\n" "$raw"; done | {
|
|
|
234
239
|
done
|
|
235
240
|
mountpoint -q /tmp || fail "/tmp is not a private tmpfs"
|
|
236
241
|
findmnt -rno OPTIONS / | grep -q "^ro" || fail "/ is not read-only"
|
|
242
|
+
# NOTE: this block lives inside the single-quoted FENCE string. A BACKTICK here
|
|
243
|
+
# would close the quoting and let bash run its contents as a command
|
|
244
|
+
# substitution — measured: eight backticks around the test -w mention made the
|
|
245
|
+
# helper exit 127 with "read-only: command not found" before the fence ran at
|
|
246
|
+
# all. Keep this comment free of backticks and of single quotes.
|
|
237
247
|
# The postcondition pairs two tests, and each is load-bearing where the other is
|
|
238
248
|
# blind. A target can be STACKED — findmnt lists one record per mount — and
|
|
239
|
-
#
|
|
249
|
+
# remount,ro,bind by path only ever reaches the TOPMOST one (measured: after
|
|
240
250
|
# remounting /run/user, a covered record still reported rw while a write through
|
|
241
251
|
# the path failed EROFS — unreachable storage, not a hole). So:
|
|
242
|
-
# * a REGULAR FILE or DIRECTORY is judged by
|
|
252
|
+
# * a REGULAR FILE or DIRECTORY is judged by test -w: it answers about the
|
|
243
253
|
# mount a write would actually land on, covered record or not;
|
|
244
254
|
# * anything else (socket, FIFO, device) is judged by the MOUNT FLAG, because
|
|
245
|
-
#
|
|
255
|
+
# test -w is meaningless there: access(W_OK) narrows the kernel read-only
|
|
246
256
|
# answer to S_ISREG/S_ISDIR/S_ISLNK (sb_permission), do_faccessat() adds a
|
|
247
257
|
# special_file() exclusion, and this fragment runs as root (CAP_DAC_OVERRIDE)
|
|
248
258
|
# before the setpriv drop. A socket therefore reported "writable" on a mount
|
|
249
259
|
# that WAS remounted read-only: measured as exit 97 for the unix socket under
|
|
250
260
|
# /mnt/wsl/podman-sockets with podman machine running, refusing every
|
|
251
|
-
# confined command including a bare
|
|
261
|
+
# confined command including a bare true (github issue #2).
|
|
252
262
|
findmnt -rno TARGET,OPTIONS | while IFS=" " read -r raw opts; do
|
|
253
263
|
target=$(printf "%b" "$raw")
|
|
254
264
|
if printf "%s\n" "$target" | grep -Eq "^(${DROP_EXEMPT})\$"; then continue; fi
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
265
|
+
# WHICH test applies is decided by the inode TYPE, read from stat, and NOT by
|
|
266
|
+
# the -f/-d shell tests. Measured: after the fence binds the workspace onto
|
|
267
|
+
# itself, a unix socket bind-mounted UNDER the workspace answers -f TRUE, while
|
|
268
|
+
# the same socket answers -f FALSE before that bind — the shell test follows the
|
|
269
|
+
# mount the path resolves to and the workspace bind changes what that is. So
|
|
270
|
+
# -f/-d cannot tell "storage" from "special file" here, and using them sent the
|
|
271
|
+
# socket down the -w branch, where it failed with "is still writable" exactly as
|
|
272
|
+
# issue #2 reported. stat -c %F reports the inode class directly.
|
|
273
|
+
kind=$(stat -c %F "$target" 2>/dev/null) || kind=""
|
|
274
|
+
# NOTE: double quotes, never single: this block is inside the single-quoted
|
|
275
|
+
# FENCE string, so a single quote would close it. The case PATTERNS below are
|
|
276
|
+
# matched literally, so their quoting form is part of the comparison.
|
|
277
|
+
case "$kind" in
|
|
278
|
+
"regular file"|"regular empty file"|directory|"symbolic link")
|
|
279
|
+
# Storage: test -w answers about the mount a write would actually land on.
|
|
280
|
+
if [[ -w "$target" ]]; then fail "$target is still writable"; fi
|
|
281
|
+
;;
|
|
282
|
+
*)
|
|
283
|
+
# Not storage (socket, fifo, device, or unreadable): the mount FLAG decides.
|
|
284
|
+
# Comma-delimited membership, not a ^ro prefix: ro is not pinned to a position.
|
|
285
|
+
case ",$opts," in *,ro,*) ;; *) fail "$target is not read-only (mount options: $opts)" ;; esac
|
|
286
|
+
;;
|
|
287
|
+
esac
|
|
261
288
|
true
|
|
262
289
|
done
|
|
263
290
|
cd "$DROP_CWD"
|