diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index 32c416a81..a6896c0fb 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -131,7 +131,17 @@ application directory at `~/.local/maple-agent.app` (Dev: `~/.local/maple-agent-dev.app`), a command in `~/.local/bin`, and a desktop entry. Python 3 is required for initial installation only. First install refuses existing installation or integration paths; subsequent upgrades use the -application's updater. Its `AppRun` entrypoint starts the +application's updater. After installation, open Activities / Show Applications +and search for **Maple Work** (or **Maple Work Dev**); that entry can be pinned +to the dock. The download includes `README.txt` with these instructions. + +On Ubuntu/GNOME, do not double-click `AppRun` or try to open it inside File Roller +(Archive Manager). Extract the entire archive first, then run `install.sh` from +a terminal as shown above. `AppRun` is a shell script, not a desktop launcher; +GNOME Files need not execute it on double-click. There is no need to change file +associations or script-execution security settings. For a portable launch without +installing, run `./maple-agent.app/AppRun` from a terminal (Dev: +`./maple-agent-dev.app/AppRun`). Its `AppRun` entrypoint starts the bundled executable and runtime directly, without an AppImage mount or FUSE. The host must provide its normal CA certificate trust store for TLS validation; existing user certificate-path overrides remain supported. The isolated CI diff --git a/apps/maple-agent/scripts/linux-bundle-README.txt b/apps/maple-agent/scripts/linux-bundle-README.txt new file mode 100644 index 000000000..403b14b74 --- /dev/null +++ b/apps/maple-agent/scripts/linux-bundle-README.txt @@ -0,0 +1,31 @@ +Maple Work for Linux +==================== + +Install once, then launch from your desktop's application menu. + +1. Extract the entire .tar.gz archive. Do not open AppRun inside Archive Manager + (File Roller): files inside the archive cannot be launched there. +2. Open a terminal in the extracted application folder (the folder containing + this README.txt and install.sh), then run: + + ./install.sh + + Run as your normal desktop user, without sudo. Python 3 is required for + installation. If extracting in a terminal, use tar -xzpf ARCHIVE.tar.gz to + preserve the required file permissions. +3. Open Activities / Show Applications and search for Maple Work (or Maple Work + Dev for a Dev bundle). Pin that application to your dock if desired. + +The installer copies the application into ~/.local and creates its application +menu entry. Keep the extracted folder intact until installation completes; after +that, the installed copy does not depend on the download folder. + +AppRun is a terminal entrypoint, not a double-click launcher. GNOME Files may +open shell scripts instead of executing them; this does not mean Maple Work is +unsupported. Do not change your system's file associations or script-execution +security settings. To run without installing, use ./AppRun from a terminal in +this extracted folder. + +Already installed? Launch Maple Work from the application menu and use its +built-in updater. The first-install helper refuses to overwrite an existing +installation and leaves account, settings and task data untouched. diff --git a/apps/maple-agent/scripts/linux-install.py b/apps/maple-agent/scripts/linux-install.py index db8c221d8..b3dd35e6c 100644 --- a/apps/maple-agent/scripts/linux-install.py +++ b/apps/maple-agent/scripts/linux-install.py @@ -254,7 +254,9 @@ def main(): "unsafe installer lock") fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) paths = install(source, home) - print("Installed " + PROFILES[descriptor["profile"]][3] + ". Launch " + str(paths["wrapper"])) + display = PROFILES[descriptor["profile"]][3] + print("Installed " + display + ". Open your application menu and search for " + display + ".") + print("Or launch from a terminal: " + shlex.quote(str(paths["wrapper"]))) print("Updates are managed by the app. Profile data was not changed.") diff --git a/apps/maple-agent/scripts/linux-release-bundle.py b/apps/maple-agent/scripts/linux-release-bundle.py index cd3575ec8..b7c35ae0f 100644 --- a/apps/maple-agent/scripts/linux-release-bundle.py +++ b/apps/maple-agent/scripts/linux-release-bundle.py @@ -203,6 +203,7 @@ def build(binary, output): stage.run(sys.executable, SCRIPTS / "linux-release-appimage.py", "--stage-only", binary, root) (root / "AppRun").write_text(bundle_apprun()) shutil.copyfile(SCRIPTS / "linux-install.sh", root / "install.sh") + shutil.copyfile(SCRIPTS / "linux-bundle-README.txt", root / "README.txt") shutil.copyfile(SCRIPTS / "linux-install.py", root / "usr/share/maple-agent/install.py") (root / "bundle.json").write_text(json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n") stage.normalize_public_permissions(root) diff --git a/apps/maple-agent/scripts/test_linux_install.py b/apps/maple-agent/scripts/test_linux_install.py index 04c683959..95cad0460 100644 --- a/apps/maple-agent/scripts/test_linux_install.py +++ b/apps/maple-agent/scripts/test_linux_install.py @@ -118,6 +118,26 @@ def test_dev_and_prod_have_distinct_bundle_commands_and_desktop_entries(self): self.assertIn("Name=Maple Work Dev\n", dev["desktop"].read_text()) self.assertTrue(set(prod.values()).isdisjoint(dev.values())) + @unittest.skipUnless(sys.platform == "linux" and shutil.which("gio"), + "GLib's desktop launcher is required") + def test_gnome_desktop_entry_launches_installed_bundle_after_download_is_removed(self): + home = self.root / "desktop home with 'quotes'" + home.mkdir() + for profile in ("prod", "dev"): + with self.subTest(profile=profile): + source = bundle_fixture(self.root / ("desktop-" + profile), profile) + paths = installer.install(source, home) + shutil.rmtree(source) + # Use the same desktop-entry implementation as GNOME, not a + # shell approximation of Exec. The fixture AppRun only echoes + # arguments; no real app, account or desktop session is used. + result = subprocess.run( + ["gio", "launch", str(paths["desktop"]), "https://example.invalid/launch"], + capture_output=True, text=True, timeout=10, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "https://example.invalid/launch\n") + def test_every_preexisting_managed_path_is_refused_without_replacement(self): for kind in ("bundle", "wrapper", "desktop", "icon", "receipt"): with self.subTest(kind=kind): diff --git a/apps/maple-agent/scripts/test_linux_release_bundle.py b/apps/maple-agent/scripts/test_linux_release_bundle.py index c8091296b..3cd29876e 100644 --- a/apps/maple-agent/scripts/test_linux_release_bundle.py +++ b/apps/maple-agent/scripts/test_linux_release_bundle.py @@ -204,6 +204,40 @@ def test_rejects_content_after_tar_end_markers(self): class LinuxBundleLauncherTests(unittest.TestCase): + def test_built_archives_ship_readable_install_instructions_for_both_profiles(self): + for profile, root_name in (("prod", ROOT), ("dev", "maple-agent-dev.app")): + with self.subTest(profile=profile), tempfile.TemporaryDirectory() as temporary: + temporary = Path(temporary) + output = temporary / f"maple-agent-{profile}-0.0.4-aaaaaaaa-linux-x86_64.tar.gz" + metadata = {"channel": profile, "version": "0.0.4", "build_number": "42", + "bundle_id": bundle.install.PROFILES[profile][2]} + + def stage_fixture(*args): + root = Path(args[-1]) + root.mkdir() + for name, data, mode, kind, link in fixture_entries(): + relative = Path(name).relative_to(ROOT) + path = root / relative + if kind == tarfile.DIRTYPE: + path.mkdir(exist_ok=True) + elif kind == tarfile.SYMTYPE: + path.symlink_to(link) + else: + path.write_bytes(data) + path.chmod(mode) + + with mock.patch.object(bundle.stage, "metadata_from_environment", return_value=metadata), \ + mock.patch.object(bundle.stage, "run", side_effect=stage_fixture), \ + mock.patch.object(bundle, "audit"), \ + mock.patch.dict(os.environ, MAPLE_PACKAGE_SOURCE_SHA="a" * 40, SOURCE_DATE_EPOCH="1"): + bundle.build(temporary / "unused-binary", output) + root = bundle.extract_archive(output, temporary / "extracted", profile) + self.assertEqual(root.name, root_name) + readme = root / "README.txt" + self.assertEqual(readme.read_bytes(), (bundle.SCRIPTS / "linux-bundle-README.txt").read_bytes()) + self.assertEqual(stat.S_IMODE(readme.stat().st_mode), 0o644) + self.assertEqual((root / "install.sh").read_bytes(), (bundle.SCRIPTS / "linux-install.sh").read_bytes()) + def test_clears_same_appdir_image_state_and_preserves_host_overrides(self): with tempfile.TemporaryDirectory() as temporary: app = Path(temporary).resolve() / "maple-agent.app"