Integrate native Rime and stabilize input, output, and rendering
- Add optional modifier-only Super binding whose tap is cancelled by other input, focus changes, grabs, locks, and session teardown. - Ship the native ferese-rime user service, Rime defaults, installer inventory, package dependencies, session environment, and docs. - Vendor Smithay 8a27023 with the SHM texture-cache format fix and add GPU regressions for format changes and resize snapshots. - Release reused CRTC owners before staging output modes, then restore changed outputs before former owners when a transaction fails. - Wait for resize-capture GPU completion, expand filtered surface damage, and preserve IME popup origin under fractional scaling. - Mark weekends like holidays in both calendar layouts.
This commit is contained in:
1 parent
a2be421974
commit
993bc0f88f
391 files changed
+130908
-76
No files matched your search
@@ -169,11 +169,11 @@ def validate_bundle(bundle, *, installed=False):
|
||||
actual = {str(path.relative_to(bundle)) for path in bundle.rglob('*') if path.is_file()}
|
||||
required = expected.keys()
|
||||
if installed:
|
||||
# Installed releases may predate the appearance wallpapers and license.
|
||||
# Installed releases may predate the appearance assets or native Rime.
|
||||
# New bundles must include them; upgrades and rollback verify the older
|
||||
# release's recorded files without requiring these later additions.
|
||||
required = required - {'wallpapers/ferese-wallpaper-dark.png', 'wallpapers/ferese-wallpaper-light.png',
|
||||
'licenses/Ferese-LICENSE.txt'}
|
||||
'licenses/Ferese-LICENSE.txt', 'ferese-rime', 'rime/default.custom.yaml'}
|
||||
required = {target for target in required if not target.startswith('activities/')}
|
||||
|
||||
if not required <= seen or actual != seen | {'manifest.json'}:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"feresectl",
|
||||
"ferese-lock",
|
||||
"ferese-polkit-agent",
|
||||
"ferese-rime",
|
||||
"xdg-desktop-portal-ferese"
|
||||
],
|
||||
"files": [
|
||||
@@ -50,6 +51,17 @@
|
||||
"mode": "0755",
|
||||
"link": true
|
||||
},
|
||||
{
|
||||
"source": "target/release/ferese-rime",
|
||||
"target": "ferese-rime",
|
||||
"mode": "0755",
|
||||
"link": true
|
||||
},
|
||||
{
|
||||
"source": "packaging/rime/default.custom.yaml",
|
||||
"target": "rime/default.custom.yaml",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "target/release/xdg-desktop-portal-ferese",
|
||||
"target": "xdg-desktop-portal-ferese",
|
||||
|
||||
+17
-5
@@ -35,16 +35,24 @@ def check_tag(tag, release_version):
|
||||
def licenses(destination, metadata):
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
report = []
|
||||
workspace_members = set(metadata.get('workspace_members', []))
|
||||
workspace_root = Path(metadata.get('workspace_root', ROOT))
|
||||
for package in sorted(metadata['packages'], key=lambda p: (p['name'], p['version'])):
|
||||
if package['source'] is None:
|
||||
if package.get('id') in workspace_members:
|
||||
continue
|
||||
|
||||
directory = Path(package['manifest_path']).parent
|
||||
source_hash = hashlib.sha256(package['source'].encode()).hexdigest()[:8]
|
||||
source = package['source']
|
||||
if source is None:
|
||||
# A vendored/path dependency is third-party code even though Cargo
|
||||
# gives it no registry/Git source. Keep notices reproducible across
|
||||
# checkout locations without exporting absolute manifest paths.
|
||||
source = 'path+' + Path(os.path.relpath(directory, workspace_root)).as_posix()
|
||||
source_hash = hashlib.sha256(source.encode()).hexdigest()[:8]
|
||||
name = f"{package['name']}-{package['version']}-{source_hash}"
|
||||
files = set()
|
||||
search = [directory]
|
||||
if package['source'].startswith('git+'):
|
||||
if source.startswith('git+'):
|
||||
for ancestor in directory.parents:
|
||||
search.append(ancestor)
|
||||
if (ancestor / '.git').exists():
|
||||
@@ -66,7 +74,7 @@ def licenses(destination, metadata):
|
||||
shutil.copyfile(path, target)
|
||||
|
||||
report.append({'name': package['name'], 'version': package['version'],
|
||||
'source': package['source'], 'license': package['license'],
|
||||
'source': source, 'license': package['license'],
|
||||
'notice_directory': name})
|
||||
|
||||
(destination / 'dependencies.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||
@@ -113,7 +121,11 @@ def stage(destdir, binary_dir, pam):
|
||||
|
||||
planned.extend([(ROOT / f'packaging/pam.d/ferese-lock.{pam}', 'etc/pam.d/ferese-lock', 0o644),
|
||||
(ROOT / 'LICENSE', 'usr/share/licenses/ferese/LICENSE', 0o644),
|
||||
(ROOT / 'README.md', 'usr/share/doc/ferese/README.md', 0o644)])
|
||||
(ROOT / 'README.md', 'usr/share/doc/ferese/README.md', 0o644),
|
||||
(ROOT / 'vendor/smithay/UPSTREAM.md',
|
||||
'usr/share/doc/ferese/vendor/smithay/UPSTREAM.md', 0o644),
|
||||
(ROOT / 'vendor/smithay/patches/shm-texture-cache-format.patch',
|
||||
'usr/share/doc/ferese/vendor/smithay/shm-texture-cache-format.patch', 0o644)])
|
||||
# Validate the complete inventory before writing any part of the package.
|
||||
for source, _, _ in planned:
|
||||
if not source.is_file():
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Real shell + native Rime test on a private Xvfb/nested Wayland/bus.
|
||||
No host GUI input or application profile is connected to this fixture.
|
||||
|
||||
FERESE_TEST_LAUNCHER_IME=1 python3 scripts/tests/test_launcher_rime_isolated.py
|
||||
Build the release compositor/shell/Rime/feresectl and debug ferese-rime-probe first.
|
||||
Synthetic screenshots and protocol traces stay under .artifacts/.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import hashlib,json,os,re,secrets,signal,shutil,subprocess,tempfile,time,unittest
|
||||
|
||||
REPO=Path(__file__).resolve().parents[2]
|
||||
BASE=REPO/'.artifacts/launcher-rime-isolated'
|
||||
COMPOSITOR=REPO/os.environ.get('FERESE_TEST_BINARY','target/release/ferese')
|
||||
PROBE=REPO/os.environ.get('FERESE_TEST_PROBE','target/debug/ferese-rime-probe')
|
||||
CTL=REPO/os.environ.get('FERESE_TEST_CTL','target/release/feresectl')
|
||||
RIME=REPO/os.environ.get('FERESE_TEST_RIME_BINARY','target/release/ferese-rime')
|
||||
|
||||
def wait(fn,label,timeout=20):
|
||||
end=time.monotonic()+timeout
|
||||
while time.monotonic()<end:
|
||||
value=fn()
|
||||
if value:return value
|
||||
time.sleep(.05)
|
||||
raise RuntimeError('Timed out: '+label)
|
||||
|
||||
def run_case(label,shell,xdisplay,xauth):
|
||||
case=BASE/label;case.mkdir(mode=0o700,exist_ok=True)
|
||||
runtime=Path(tempfile.mkdtemp(prefix='flime-',dir='/run/user/1000'))
|
||||
for name in ['config/ferese','data','state','cache','home','rime','tmp']:(case/name).mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
(case/'config/ferese/config.kdl').write_text('xwayland { enabled #false; }\nanimations { enabled #false; }\nstatus { keybinding-guide #false; }\n')
|
||||
(case/'rime/default.custom.yaml').write_text('patch:\n schema_list:\n - schema: luna_pinyin_simp\n')
|
||||
conf=runtime/'bus.conf';conf.write_text('<busconfig><type>session</type><listen>unix:path='+str(runtime/'bus')+'</listen><policy context="default"><allow send_destination="*"/><allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
|
||||
env={k:v for k,v in os.environ.items() if k not in ['WAYLAND_DISPLAY','WAYLAND_SOCKET','FERESE_SOCKET','FERESE_SHELL_CONTROL_SOCKET','DBUS_SESSION_BUS_ADDRESS','GTK_IM_MODULE','QT_IM_MODULE','IBUS_ADDRESS','XMODIFIERS']}
|
||||
env.update(HOME=str(case/'home'),XDG_RUNTIME_DIR=str(runtime),XDG_CONFIG_HOME=str(case/'config'),XDG_DATA_HOME=str(case/'data'),XDG_CACHE_HOME=str(case/'cache'),XDG_STATE_HOME=str(case/'state'),XDG_DATA_DIRS='/usr/local/share:/usr/share',DISPLAY=xdisplay,XAUTHORITY=str(xauth),TMPDIR=str(case/'tmp'),FERESE_ENABLE_INPUT_METHOD='1',FERESE_ENABLE_SCREENCOPY='1',FERESE_SESSION_IMPORT_ENV='0',FERESE_RIME_CONTROL=str(runtime/'rime.sock'),LIBGL_ALWAYS_SOFTWARE='1',__EGL_VENDOR_LIBRARY_FILENAMES='/usr/share/glvnd/egl_vendor.d/50_mesa.json',WINIT_UNIX_BACKEND='x11',ICED_BACKEND='tiny-skia',PATH='/usr/bin',RUST_LOG='ferese=info')
|
||||
children=[];log=(case/'session.log').open('w')
|
||||
try:
|
||||
bus=subprocess.Popen(['dbus-daemon','--config-file='+str(conf),'--nofork','--print-address=1'],env=env,stdout=subprocess.PIPE,stderr=log,text=True,start_new_session=True);children.append(bus)
|
||||
env['DBUS_SESSION_BUS_ADDRESS']=bus.stdout.readline().strip();assert env['DBUS_SESSION_BUS_ADDRESS'].startswith('unix:')
|
||||
comp=subprocess.Popen([str(COMPOSITOR),'--backend','nested','--supervise-client','--grant-effects','--grant-shell-control','--','/usr/bin/env','WAYLAND_DEBUG=client',str(shell)],env=env,stdout=log,stderr=log,start_new_session=True);children.append(comp)
|
||||
def find_socket():
|
||||
candidates=list(runtime.glob('ferese/instances/*/control.sock'))
|
||||
return next((p for p in candidates if p.is_socket()),None)
|
||||
sock=wait(find_socket,'private IPC')
|
||||
def ctl(*args):return subprocess.check_output([str(CTL),'--socket',str(sock),*map(str,args)],env=env,text=True)
|
||||
outputs=json.loads(ctl('-j','outputs'));assert len(outputs)==1 and outputs[0]['name']=='ferese-winit',outputs
|
||||
wayland=wait(lambda:next((p for p in runtime.glob('wayland-*') if p.is_socket()),None),'private Wayland')
|
||||
child_env=env|{'WAYLAND_DISPLAY':str(wayland),'FERESE_SOCKET':str(sock)}
|
||||
ime=subprocess.Popen([str(RIME),'--user-data',str(case/'rime'),'--shared-data','/usr/share/rime-data'],env=child_env,stdout=log,stderr=log,start_new_session=True);children.append(ime)
|
||||
wait(lambda:(runtime/'rime.sock').is_socket(),'native Rime')
|
||||
def status():return json.loads(subprocess.check_output([str(RIME),'--status'],env=child_env,text=True))['state']
|
||||
wait(lambda: 'ferese_shell_manager_v1' in (case/'session.log').read_text(), 'real shell control binding')
|
||||
time.sleep(.5);ctl('launcher','toggle')
|
||||
wait(lambda:status()['active'],'launcher text-input enable')
|
||||
subprocess.run([str(RIME),'--mode','rime'],env=child_env,capture_output=True,text=True,check=True)
|
||||
def cursor_x():
|
||||
return [int(x) for x in re.findall(r'zwp_text_input_v3[@#]\d+\.set_cursor_rectangle\((-?\d+),', (case/'session.log').read_text())]
|
||||
initial=wait(lambda:cursor_x(),'initial caret')[-1]
|
||||
testenv=child_env|{'FERESE_TEST_INPUT':'1'}
|
||||
def probe(*args):
|
||||
p=subprocess.run([str(PROBE),*args],env=testenv,capture_output=True,text=True,timeout=10)
|
||||
assert p.returncode==0,p.stderr
|
||||
probe('--keys','nihao');time.sleep(.3)
|
||||
(case/'preedit.png').write_bytes(subprocess.check_output([str(CTL),'--socket',str(sock),'screenshot'],env=env))
|
||||
probe('--keys',' ');time.sleep(.6)
|
||||
(case/'commit.png').write_bytes(subprocess.check_output([str(CTL),'--socket',str(sock),'screenshot'],env=env))
|
||||
trace=(case/'session.log').read_text();xs=cursor_x()
|
||||
result={'label':label,'shell':str(shell),'shellSha256':hashlib.sha256(shell.read_bytes()).hexdigest(),'compositorPid':comp.pid,'singlePrivateOutputVerified':True,'nativeRimePid':ime.pid,'launcherImeActive':status()['active'],'committedSyntheticHelloSeenOnProtocol':bool(re.search(r'commit_string\("你好"\)',trace)),'initialCaretX':initial,'caretXs':xs,'committedTextMovedCaret':bool(xs and xs[-1]>initial+8),'privateHeadlessX11Parent':xdisplay,'hostGuiUsed':False,'userProfilesUsed':False,'messagesSent':False}
|
||||
(case/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n');print(json.dumps(result,ensure_ascii=False))
|
||||
probe('--chord','Control_L+a','--key','BackSpace','--key','Escape')
|
||||
return result
|
||||
finally:
|
||||
for child in reversed(children):
|
||||
if child.poll() is None:
|
||||
os.killpg(child.pid,signal.SIGTERM)
|
||||
try:child.wait(timeout=4)
|
||||
except subprocess.TimeoutExpired:os.killpg(child.pid,signal.SIGKILL);child.wait()
|
||||
log.close()
|
||||
# Keep synthetic schema/screenshots/logs as evidence; remove dead private sockets.
|
||||
import shutil
|
||||
shutil.rmtree(runtime)
|
||||
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get('FERESE_TEST_LAUNCHER_IME') == '1', 'opt-in isolated launcher/Rime test')
|
||||
class LauncherRimeTest(unittest.TestCase):
|
||||
def test_preedit_and_commit_reach_launcher(self):
|
||||
for tool in ['Xvfb','xauth','dbus-daemon']:
|
||||
if shutil.which(tool) is None:self.skipTest('missing fixture tool: '+tool)
|
||||
shell=REPO/os.environ.get('FERESE_TEST_SHELL','target/release/ferese-shell')
|
||||
for binary in [COMPOSITOR,PROBE,CTL,RIME,shell]:
|
||||
if not binary.is_file():self.skipTest('build required binary: '+str(binary))
|
||||
BASE.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
auth=BASE/'fixture.Xauthority';cookie=secrets.token_hex(16);xvfb=None
|
||||
with (BASE/'xvfb.log').open('w') as log:
|
||||
try:
|
||||
for number in range(190,230):
|
||||
if Path(f'/tmp/.X{number}-lock').exists() or Path(f'/tmp/.X11-unix/X{number}').exists():continue
|
||||
display=':'+str(number)
|
||||
subprocess.run(['xauth','-f',str(auth),'add',display,'MIT-MAGIC-COOKIE-1',cookie],capture_output=True,check=True)
|
||||
auth.chmod(0o600)
|
||||
xvfb=subprocess.Popen(['Xvfb',display,'-screen','0','1600x1000x24','-nolisten','tcp','-auth',str(auth)],stdout=log,stderr=log,start_new_session=True)
|
||||
try:wait(lambda:Path(f'/tmp/.X11-unix/X{number}').is_socket(),'Xvfb startup',5)
|
||||
except RuntimeError:
|
||||
if xvfb.poll() is None:os.killpg(xvfb.pid,signal.SIGTERM);xvfb.wait()
|
||||
xvfb=None;continue
|
||||
break
|
||||
self.assertIsNotNone(xvfb,'No private Xvfb display available')
|
||||
result=run_case('current',shell.resolve(),display,auth)
|
||||
self.assertTrue(result['singlePrivateOutputVerified'])
|
||||
self.assertTrue(result['committedSyntheticHelloSeenOnProtocol'])
|
||||
self.assertTrue(result['committedTextMovedCaret'], 'Wayland commit arrived but did not update launcher text/caret')
|
||||
finally:
|
||||
if xvfb is not None and xvfb.poll() is None:os.killpg(xvfb.pid,signal.SIGTERM);xvfb.wait(timeout=5)
|
||||
auth.unlink(missing_ok=True)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -76,6 +76,14 @@ class PackageTests(unittest.TestCase):
|
||||
self.assertTrue((dest / 'usr/share/ferese/wallpapers/ferese.png').is_file())
|
||||
self.assertEqual((dest / 'usr/libexec/ferese/ferese-session').stat().st_mode & 0o777, 0o755)
|
||||
self.assertEqual((dest / 'usr/libexec/ferese').stat().st_mode & 0o777, 0o755)
|
||||
self.assertTrue((dest / 'usr/libexec/ferese/ferese-rime').is_file())
|
||||
self.assertIn('schema: luna_pinyin_simp',
|
||||
(dest / 'usr/libexec/ferese/rime/default.custom.yaml').read_text())
|
||||
provenance = dest / 'usr/share/doc/ferese/vendor/smithay'
|
||||
for name in ('UPSTREAM.md', 'shm-texture-cache-format.patch'):
|
||||
origin = PACKAGE.ROOT / 'vendor/smithay' / (
|
||||
name if name == 'UPSTREAM.md' else 'patches/' + name)
|
||||
self.assertEqual((provenance / name).read_bytes(), origin.read_bytes())
|
||||
|
||||
def test_same_version_from_different_sources_keeps_both_licenses(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -107,6 +115,36 @@ class PackageTests(unittest.TestCase):
|
||||
self.assertEqual(report[0]['license'], 'MIT')
|
||||
self.assertEqual(report[0]['source'], 'registry+test')
|
||||
|
||||
def test_vendored_notices_exclude_workspace_members_and_are_relocatable(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
reports = []
|
||||
for checkout in ('first-checkout', 'second-checkout'):
|
||||
workspace = root / checkout / 'ferese'
|
||||
packages = []
|
||||
for name, folder, license_text in (
|
||||
('ferese', workspace / 'compositor', 'workspace notice'),
|
||||
('smithay', workspace / 'vendor/smithay', 'Smithay MIT notice'),
|
||||
('external', workspace.parent / 'shared/external', 'external notice')):
|
||||
folder.mkdir(parents=True)
|
||||
(folder / 'LICENSE.txt').write_text(license_text)
|
||||
packages.append({'id': f'path+file://{folder}#{name}@0.7.0',
|
||||
'name': name, 'version': '0.7.0', 'source': None,
|
||||
'manifest_path': str(folder / 'Cargo.toml'),
|
||||
'license': 'MIT', 'license_file': None})
|
||||
destination = workspace / 'notices'
|
||||
PACKAGE.licenses(destination, {'packages': packages,
|
||||
'workspace_members': [packages[0]['id']], 'workspace_root': str(workspace)})
|
||||
report = json.loads((destination / 'dependencies.json').read_text())
|
||||
self.assertEqual({p['name']: p['source'] for p in report}, {
|
||||
'smithay': 'path+vendor/smithay', 'external': 'path+../shared/external'})
|
||||
notices = {p['name']: (destination / p['notice_directory'] / 'LICENSE.txt').read_text()
|
||||
for p in report}
|
||||
self.assertEqual(notices, {'smithay': 'Smithay MIT notice', 'external': 'external notice'})
|
||||
self.assertNotIn(str(root), (destination / 'dependencies.json').read_text())
|
||||
reports.append(report)
|
||||
self.assertEqual(reports[0], reports[1])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -141,6 +141,18 @@ m.main()
|
||||
self.assertEqual(os.readlink(self.base / 'current'), 'releases/first')
|
||||
self.assertEqual(installer.Installation(self.root).manifest('first')['release'], 'first')
|
||||
|
||||
def test_upgrade_and_rollback_accept_installed_release_before_native_rime(self):
|
||||
self.install()
|
||||
self.without_files(self.base / 'releases/first', {'ferese-rime', 'rime/default.custom.yaml'})
|
||||
# Older releases did not publish the native input command either.
|
||||
(self.root / 'usr/local/bin/ferese-rime').unlink()
|
||||
self.install(1)
|
||||
self.assertTrue((self.root / 'usr/local/bin/ferese-rime').is_symlink())
|
||||
self.assertTrue((self.base / 'releases/second/ferese-rime').is_file())
|
||||
self.run_command('rollback')
|
||||
self.assertFalse((self.root / 'usr/local/bin/ferese-rime').exists())
|
||||
self.assertEqual(installer.Installation(self.root).manifest('first')['release'], 'first')
|
||||
|
||||
def test_upgrade_and_rollback_accept_installed_release_before_appearance_wallpapers(self):
|
||||
self.install()
|
||||
legacy = self.base / 'releases/first'
|
||||
|
||||
@@ -13,19 +13,46 @@ SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-session-shell'
|
||||
|
||||
class SessionStartupTest(unittest.TestCase):
|
||||
def run_session(self, direct=True, fail_target=False, shell_status=0, terminate=False,
|
||||
display=None, xauthority=None, extra_env=None, programs_override=None):
|
||||
display=None, xauthority=None, extra_env=None, programs_override=None,
|
||||
user_customization=None, restart_after_kill=False):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
helper = root / 'ferese-session-shell'
|
||||
shutil.copyfile(SCRIPT, helper)
|
||||
(root / 'rime').mkdir()
|
||||
shutil.copyfile(SCRIPT.parent / 'rime/default.custom.yaml', root / 'rime/default.custom.yaml')
|
||||
shared = root / 'rime-shared'
|
||||
shared.mkdir()
|
||||
(shared / 'luna_pinyin_simp.schema.yaml').write_text('fixture')
|
||||
units = root / 'units'
|
||||
units.mkdir()
|
||||
foreign_unit = units / '[email protected]'
|
||||
foreign_unit.write_text('foreign service')
|
||||
if user_customization is not None:
|
||||
user_rime = root / 'data/ferese/rime'
|
||||
user_rime.mkdir(parents=True)
|
||||
(user_rime / 'default.custom.yaml').write_text(user_customization)
|
||||
programs = {
|
||||
'dbus-update-activation-environment': (
|
||||
'printf "import:%s\\n" "$WAYLAND_DISPLAY" >> "$TEST_ROOT/events"\n'
|
||||
'printf "vars:%s\\n" "$*" >> "$TEST_ROOT/events"\n'),
|
||||
'dbus-send': 'echo reload-bus >> "$TEST_ROOT/events"\n',
|
||||
'systemctl': 'echo "$*" >> "$TEST_ROOT/events"\nif [[ $* == "--user start ferese-session.target" && $TEST_FAIL == 1 ]]; then exit 1; fi\n',
|
||||
'systemctl': (
|
||||
'echo "$*" >> "$TEST_ROOT/events"\n'
|
||||
'if [[ $* == "--user start ferese-session.target" && $TEST_FAIL == 1 ]]; then exit 1; fi\n'
|
||||
'if [[ ${2:-} == show && ${3:-} == ferese-rime-session@* ]]; then\n'
|
||||
' if [[ -f $TEST_ROOT/units/$3 ]]; then echo loaded; else echo not-found; fi\n'
|
||||
'elif [[ ${2:-} == stop && ${3:-} == ferese-rime-session@* ]]; then\n'
|
||||
' rm -f -- "$TEST_ROOT/units/$3"\nfi\n'),
|
||||
'ferese-polkit-agent': 'exec sleep 30\n',
|
||||
'ferese-shell': 'printf "shell:%s\\n" "$WAYLAND_SOCKET" >> "$TEST_ROOT/events"\nif [[ $TEST_WAIT == 1 ]]; then exec sleep 30; fi\nexit "$TEST_STATUS"\n',
|
||||
'ferese-rime': 'exit 0\n',
|
||||
'systemd-run': (
|
||||
'for arg; do if [[ $arg == --unit=* ]]; then unit=${arg#--unit=}; fi; done\n'
|
||||
'(set -o noclobber; echo running > "$TEST_ROOT/units/$unit") || exit 1\n'
|
||||
'printf "rime-arg:%s\\n" "$@" >> "$TEST_ROOT/events"\n'
|
||||
'printf "rime-config:%s\\n" "$(cat "$XDG_DATA_HOME/ferese/rime/default.custom.yaml")" >> "$TEST_ROOT/events"\n'
|
||||
'touch "$TEST_ROOT/rime-started"\n'),
|
||||
}
|
||||
programs.update(programs_override or {})
|
||||
for name, body in programs.items():
|
||||
@@ -36,7 +63,10 @@ class SessionStartupTest(unittest.TestCase):
|
||||
FERESE_SESSION_MODE='desktop',
|
||||
FERESE_SESSION_IMPORT_ENV=str(int(direct)), WAYLAND_DISPLAY='private-display',
|
||||
FERESE_PUBLIC_WAYLAND_DISPLAY='public-display', WAYLAND_SOCKET='77',
|
||||
TEST_FAIL=str(int(fail_target)), TEST_STATUS=str(shell_status), TEST_WAIT=str(int(terminate)))
|
||||
FERESE_SOCKET=str(root / 'control.sock'),
|
||||
XDG_DATA_HOME=str(root / 'data'), FERESE_RIME_SHARED_DATA=str(shared),
|
||||
TEST_FAIL=str(int(fail_target)), TEST_STATUS=str(shell_status),
|
||||
TEST_WAIT=str(int(terminate or restart_after_kill)))
|
||||
env.pop('DISPLAY', None)
|
||||
env.pop('XAUTHORITY', None)
|
||||
if display is not None:
|
||||
@@ -44,7 +74,25 @@ class SessionStartupTest(unittest.TestCase):
|
||||
if xauthority is not None:
|
||||
env['XAUTHORITY'] = xauthority
|
||||
env.update(extra_env or {})
|
||||
child = subprocess.Popen(['bash', str(helper)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
child = subprocess.Popen(['bash', str(helper)], env=env, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, start_new_session=True)
|
||||
if restart_after_kill:
|
||||
deadline = time.monotonic() + 3
|
||||
while not (root / 'rime-started').exists():
|
||||
if time.monotonic() > deadline:
|
||||
os.killpg(child.pid, signal.SIGKILL)
|
||||
self.fail('Native input unit did not start')
|
||||
time.sleep(.01)
|
||||
child.kill()
|
||||
child.wait(timeout=2)
|
||||
# The helper cannot run cleanup after SIGKILL. Dispose of fake
|
||||
# shell/agent processes, while its fake systemd service survives.
|
||||
os.killpg(child.pid, signal.SIGKILL)
|
||||
child.communicate(timeout=2)
|
||||
self.assertEqual(len(list(units.glob('*.service'))), 2)
|
||||
env.update(TEST_WAIT='0', FERESE_CLIENT_RESTART='1')
|
||||
child = subprocess.Popen(['bash', str(helper)], env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if terminate:
|
||||
deadline = time.monotonic() + 3
|
||||
while not (root / 'events').exists() or 'shell:77' not in (root / 'events').read_text():
|
||||
@@ -54,6 +102,7 @@ class SessionStartupTest(unittest.TestCase):
|
||||
time.sleep(.01)
|
||||
child.send_signal(signal.SIGTERM)
|
||||
child.communicate(timeout=4)
|
||||
self.assertEqual(foreign_unit.read_text(), 'foreign service')
|
||||
return child.returncode, (root / 'events').read_text().splitlines()
|
||||
|
||||
@staticmethod
|
||||
@@ -78,6 +127,78 @@ class SessionStartupTest(unittest.TestCase):
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIn('FERESE_SOCKET=/run/user/1000/ferese/control.sock', self.imported_vars(events))
|
||||
|
||||
def test_native_rime_is_release_pinned_supervised_and_stopped_with_its_session(self):
|
||||
status, events = self.run_session(extra_env={'FERESE_ENABLE_INPUT_METHOD': '1'})
|
||||
self.assertEqual(status, 0)
|
||||
args = [event.removeprefix('rime-arg:') for event in events if event.startswith('rime-arg:')]
|
||||
self.assertIn('--service-type=notify', args)
|
||||
for setting in ('Restart=on-failure', 'WatchdogSec=10', 'StartLimitBurst=5',
|
||||
'BindsTo=ferese-session.target'):
|
||||
self.assertIn('--property=' + setting, args)
|
||||
self.assertIn('WAYLAND_DISPLAY=public-display', args)
|
||||
self.assertIn('WAYLAND_SOCKET', args)
|
||||
self.assertIn('FERESE_SHELL_CONTROL_SOCKET', args)
|
||||
executable = next(arg for arg in args if arg.endswith('/ferese-rime'))
|
||||
self.assertNotEqual(executable, '/usr/bin/ferese-rime')
|
||||
unit = next(arg.removeprefix('--unit=') for arg in args if arg.startswith('--unit='))
|
||||
self.assertIn('--user stop ' + unit, events)
|
||||
self.assertEqual(events[-1], '--user stop ferese-session.target')
|
||||
self.assertIn(' - schema: luna_pinyin_simp', events)
|
||||
|
||||
def test_native_rime_preserves_existing_user_customization(self):
|
||||
status, events = self.run_session(extra_env={'FERESE_ENABLE_INPUT_METHOD': '1'},
|
||||
user_customization='my-custom-dictionary: preserved\n')
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIn('rime-config:my-custom-dictionary: preserved', events)
|
||||
|
||||
def test_hard_killed_helper_replaces_only_its_existing_native_rime_unit(self):
|
||||
status, events = self.run_session(extra_env={'FERESE_ENABLE_INPUT_METHOD': '1'},
|
||||
restart_after_kill=True)
|
||||
self.assertEqual(status, 0)
|
||||
starts = [event.removeprefix('rime-arg:--unit=') for event in events
|
||||
if event.startswith('rime-arg:--unit=')]
|
||||
self.assertEqual(len(starts), 2)
|
||||
self.assertEqual(starts[0], starts[1])
|
||||
stopped = '--user stop ' + starts[0]
|
||||
start_events = [index for index, event in enumerate(events) if event == 'rime-arg:--unit=' + starts[0]]
|
||||
self.assertIn(stopped, events[start_events[0] + 1:start_events[1]])
|
||||
self.assertNotIn('--user stop [email protected]', events)
|
||||
|
||||
def test_unresponsive_old_rime_unit_blocks_replacement_without_stopping_the_desktop(self):
|
||||
# Model a timed-out manager request. The helper must not create a second
|
||||
# input service while it cannot confirm the state of the first one.
|
||||
status, events = self.run_session(extra_env={'FERESE_ENABLE_INPUT_METHOD': '1'},
|
||||
programs_override={'systemctl': (
|
||||
'echo "$*" >> "$TEST_ROOT/events"\n'
|
||||
'if [[ ${2:-} == show ]]; then exit 124; fi\n')})
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIn('shell:77', events)
|
||||
self.assertFalse(any(event.startswith('rime-arg:') for event in events))
|
||||
|
||||
def test_failed_rime_start_leaves_the_desktop_running(self):
|
||||
status, events = self.run_session(extra_env={'FERESE_ENABLE_INPUT_METHOD': '1'},
|
||||
programs_override={'systemd-run': 'exit 7\n'})
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIn('shell:77', events)
|
||||
self.assertFalse(any(event.startswith('--user stop ferese-rime-') for event in events))
|
||||
|
||||
def test_nested_session_never_starts_native_rime(self):
|
||||
status, events = self.run_session(direct=False, extra_env={'FERESE_ENABLE_INPUT_METHOD': '1'})
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(events, ['shell:77'])
|
||||
|
||||
def test_native_input_environment_is_imported_for_activated_applications(self):
|
||||
status, events = self.run_session(extra_env={'FERESE_ENABLE_INPUT_METHOD': '1',
|
||||
'GTK_IM_MODULE': '', 'QT_IM_MODULE': 'wayland', 'QT_IM_MODULES': 'wayland',
|
||||
'IBUS_ADDRESS': '', 'XMODIFIERS': '@im=ibus', 'FERESE_RIME_CONTROL': '/private/rime.sock'})
|
||||
self.assertEqual(status, 0)
|
||||
imported = self.imported_vars(events)
|
||||
for name in ('QT_IM_MODULE', 'QT_IM_MODULES', 'IBUS_ADDRESS', 'FERESE_RIME_CONTROL'):
|
||||
self.assertIn(name, imported)
|
||||
self.assertIn('GTK_IM_MODULE=', imported)
|
||||
self.assertIn('SDL_IM_MODULE=', imported)
|
||||
self.assertIn('XMODIFIERS=@im=ibus', imported)
|
||||
|
||||
def test_a_managed_x11_endpoint_is_imported_for_dbus_activators(self):
|
||||
status, events = self.run_session(display=':7', xauthority='/run/user/1000/ferese-xauth-abcd')
|
||||
self.assertEqual(status, 0)
|
||||
@@ -161,5 +282,46 @@ class SessionStartupTest(unittest.TestCase):
|
||||
self.assertEqual(events.count('autostart'), int(restarted == '0'))
|
||||
|
||||
|
||||
class SessionLauncherTest(unittest.TestCase):
|
||||
def launch(self, nested=False, enabled=None):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
launcher = root / 'ferese-session'
|
||||
shutil.copyfile(SCRIPT.parent / 'ferese-session', launcher)
|
||||
for name, body in {'ferese': '/usr/bin/env > "$TEST_ROOT/environment"\n',
|
||||
'systemctl': 'exit 0\n'}.items():
|
||||
path = root / name
|
||||
path.write_text('#!/bin/bash\n' + body)
|
||||
path.chmod(0o755)
|
||||
env = dict(os.environ, PATH=f'{root}:{os.environ["PATH"]}', TEST_ROOT=directory,
|
||||
HOME=directory, XDG_RUNTIME_DIR=directory, DBUS_SESSION_BUS_ADDRESS='fixture',
|
||||
XDG_STATE_HOME=str(root / 'state'), GTK_IM_MODULE='ibus', QT_IM_MODULE='ibus',
|
||||
QT_IM_MODULES='wayland;ibus', IBUS_ADDRESS='old-ibus', XMODIFIERS='@im=ibus', SDL_IM_MODULE='ibus')
|
||||
env.pop('FERESE_ENABLE_INPUT_METHOD', None)
|
||||
if enabled is not None:
|
||||
env['FERESE_ENABLE_INPUT_METHOD'] = enabled
|
||||
subprocess.run(['bash', str(launcher), *(['--nested'] if nested else [])],
|
||||
env=env, capture_output=True, check=True)
|
||||
return dict(line.split('=', 1) for line in (root / 'environment').read_text().splitlines())
|
||||
|
||||
def test_drm_session_overrides_inherited_ibus_only_for_native_input(self):
|
||||
env = self.launch()
|
||||
self.assertEqual(env['FERESE_ENABLE_INPUT_METHOD'], '1')
|
||||
self.assertNotIn('GTK_IM_MODULE', env)
|
||||
for variable in ('QT_IM_MODULE', 'QT_IM_MODULES'):
|
||||
self.assertEqual(env[variable], 'wayland')
|
||||
self.assertEqual(env['IBUS_ADDRESS'], '')
|
||||
self.assertEqual(env['XMODIFIERS'], '@im=ibus')
|
||||
self.assertNotIn('SDL_IM_MODULE', env)
|
||||
self.assertTrue(env['FERESE_RIME_CONTROL'].endswith('/ferese/rime-control.sock'))
|
||||
|
||||
def test_nested_and_explicit_opt_out_preserve_input_configuration(self):
|
||||
for options in ({'nested': True}, {'enabled': '0'}):
|
||||
with self.subTest(options=options):
|
||||
env = self.launch(**options)
|
||||
self.assertEqual(env['QT_IM_MODULE'], 'ibus')
|
||||
self.assertNotEqual(env.get('FERESE_ENABLE_INPUT_METHOD'), '1')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user