From dc58534dd64f4fbb8fa0b3aa5f1180d0486b8d4a Mon Sep 17 00:00:00 2001 From: BestCodes <106822363+The-Best-Codes@users.noreply.github.com> Date: Fri, 29 May 2026 09:31:44 -0500 Subject: [PATCH] fixes --- flatpak/README.md | 50 ++++++++++++++++++++++++++++++---- flatpak/forge-post-package.js | 12 ++++++++ flatpak/generated-sources.json | 2 +- io.github.block.Goose.yaml | 33 +++++++++++++++++----- ui/package.json | 1 + 5 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 flatpak/forge-post-package.js diff --git a/flatpak/README.md b/flatpak/README.md index 8083f59ea2..b3b901bfa3 100644 --- a/flatpak/README.md +++ b/flatpak/README.md @@ -10,6 +10,7 @@ Files in this workflow: - `flatpak/goose-desktop.sh`: launcher wrapper used inside the sandbox - `flatpak/cargo-sources.json`: offline Cargo dependency sources generated from `Cargo.lock` - `flatpak/generated-sources.json`: offline pnpm dependency sources generated from `ui/pnpm-lock.yaml` +- `flatpak/forge-post-package.js`: local hook used during `electron-forge package` to copy the packaged app into `/app/lib/goose` ## Important distinction @@ -28,10 +29,14 @@ Instead it: 1. Builds `goose-server` from source inside the Flatpak SDK. 2. Copies the resulting `goosed` binary into `ui/desktop/src/bin/goosed`. -3. Installs the `ui/` pnpm workspace offline from `flatpak/generated-sources.json`. -4. Runs `electron-forge package --platform=linux` inside the Flatpak build sandbox. -5. Copies the packaged app into `/app/lib/goose`. -6. Installs the desktop file, metainfo, icons, and wrapper script. +3. Bootstraps a pinned `pnpm@10.30.3` from `pnpm-10.30.3.tgz` and exposes real `pnpm` and `pnpx` shims on `PATH`. +4. Installs the `ui/` pnpm workspace offline from `flatpak/generated-sources.json`. +5. Patches `ui/desktop/forge.config.ts` during the build to point Electron Forge at the staged offline Electron ZIP cache and add a `postPackage` hook. +6. Runs `electron-forge package --platform=linux` inside the Flatpak build sandbox. +7. Copies the packaged app into `/app/lib/goose` from Forge's actual `outputPaths`. +8. Installs the desktop file, metainfo, icons, and wrapper script. + +The manifest builds from the Goose release tarball, not from the working tree. Because that tarball does not include the repo's `flatpak/` directory, the local packaging files in `flatpak/` are added as explicit `type: file` sources in `io.github.block.Goose.yaml`. Runtime behavior also differs from the Electron Forge `.flatpak` artifact: @@ -53,12 +58,21 @@ Required tools: - `appstreamcli` - `desktop-file-validate` -You also need the Flathub remote configured: +If you build with `--user`, you need the user-scoped Flathub remote configured: ```bash -flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo +flatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo ``` +On this host, `flatpak-builder` also required disabling rofiles FUSE because `rofiles-fuse` failed with: + +```text +fusermount3: failed to access mountpoint ... Permission denied +Error: Failure spawning rofiles-fuse, exit_status: 1024 +``` + +The local workaround was `--disable-rofiles-fuse`. + Build the manifest locally: ```bash @@ -67,6 +81,7 @@ flatpak-builder \ --install \ --install-deps-from=flathub \ --force-clean \ + --disable-rofiles-fuse \ builddir \ io.github.block.Goose.yaml ``` @@ -84,6 +99,7 @@ flatpak-builder \ --user \ --install-deps-from=flathub \ --force-clean \ + --disable-rofiles-fuse \ --repo=repo \ builddir \ io.github.block.Goose.yaml @@ -127,6 +143,28 @@ Those are expected for the current packaging model: - `--talk-name=org.freedesktop.Flatpak` is intentional because Goose needs `flatpak-spawn --host` for host-side shell execution - `appid-url-not-reachable` currently points at the legacy app ID owner URL and should be fixed before or during Flathub review +## Proven local result + +The following command was run locally and completed successfully: + +```bash +flatpak-builder \ + --user \ + --install \ + --install-deps-from=flathub \ + --force-clean \ + --disable-rofiles-fuse \ + builddir \ + io.github.block.Goose.yaml +``` + +That run: + +- built `goose-server` +- installed the pnpm workspace fully offline +- packaged the Electron app fully offline +- exported and installed `app/io.github.block.Goose/x86_64/master` + ## When to regenerate generated sources Regenerate the dependency manifests whenever any of these change: diff --git a/flatpak/forge-post-package.js b/flatpak/forge-post-package.js new file mode 100644 index 0000000000..95653a2571 --- /dev/null +++ b/flatpak/forge-post-package.js @@ -0,0 +1,12 @@ +const fs = require('node:fs/promises'); + +module.exports = async (_forgeConfig, { outputPaths }) => { + const packageDir = outputPaths.find((outputPath) => outputPath.includes('-linux-')); + + if (!packageDir) { + throw new Error(`Packaged Goose app directory not found in output paths: ${outputPaths.join(', ')}`); + } + + await fs.rm('/app/lib/goose', { recursive: true, force: true }); + await fs.cp(packageDir, '/app/lib/goose', { recursive: true }); +}; diff --git a/flatpak/generated-sources.json b/flatpak/generated-sources.json index 48d40f20dc..962f7d5dd9 100644 --- a/flatpak/generated-sources.json +++ b/flatpak/generated-sources.json @@ -12860,7 +12860,7 @@ }, { "type": "inline", - "contents": "from __future__ import annotations\n\nimport base64\nimport contextlib\nimport hashlib\nimport json\nimport os\nimport re\nimport sqlite3\nimport struct\nimport sys\nimport tarfile\nimport time\nfrom collections.abc import Mapping\n\n_SANITIZE_RE = re.compile(r'[\\\\/:*?\"<>|]')\n_MAX_LENGTH_WITHOUT_HASH = 120\n\n\ndef _msgpack_pack(obj: object) -> bytes:\n \"\"\"Minimal msgpack packer matching msgpackr with useRecords: true.\n\n msgpackr reserves the byte range 0x40-0x7F for record IDs when useRecords\n is enabled (pnpm's config). Standard msgpack uses 0x00-0x7F for positive\n fixints, but this packer caps them at 0x3F and emits a uint 8 prefix (0xcc)\n for 64-255 to avoid aliasing with record ID bytes.\n \"\"\"\n\n def _pack_int(val: int) -> bytes:\n # Positive fixint capped at 0x3F \u2014 0x40-0x7F are msgpackr record IDs\n if 0 <= val <= 0x3F:\n return val.to_bytes(1, 'big')\n if -32 <= val < 0:\n return val.to_bytes(1, 'big', signed=True)\n if val >= 0:\n if val <= 0xFF:\n return b'\\xcc' + val.to_bytes(1, 'big')\n if val <= 0xFFFF:\n return b'\\xcd' + val.to_bytes(2, 'big')\n if val <= 0xFFFFFFFF:\n return b'\\xce' + val.to_bytes(4, 'big')\n return b'\\xcf' + val.to_bytes(8, 'big')\n if val >= -0x80:\n return b'\\xd0' + val.to_bytes(1, 'big', signed=True)\n if val >= -0x8000:\n return b'\\xd1' + val.to_bytes(2, 'big', signed=True)\n if val >= -0x80000000:\n return b'\\xd2' + val.to_bytes(4, 'big', signed=True)\n return b'\\xd3' + val.to_bytes(8, 'big', signed=True)\n\n def _pack_str(val: str) -> bytes:\n data = val.encode('utf-8')\n length = len(data)\n if length <= 0x1F:\n return (0xA0 | length).to_bytes(1, 'big') + data\n if length <= 0xFF:\n return b'\\xd9' + length.to_bytes(1, 'big') + data\n if length <= 0xFFFF:\n return b'\\xda' + length.to_bytes(2, 'big') + data\n return b'\\xdb' + length.to_bytes(4, 'big') + data\n\n if obj is None:\n return b'\\xc0'\n if isinstance(obj, bool):\n return b'\\xc3' if obj else b'\\xc2'\n if isinstance(obj, int):\n return _pack_int(obj)\n if isinstance(obj, float):\n return b'\\xcb' + struct.pack('>d', obj)\n if isinstance(obj, str):\n return _pack_str(obj)\n if isinstance(obj, dict):\n length = len(obj)\n if length <= 0x0F:\n result = (0x80 | length).to_bytes(1, 'big')\n elif length <= 0xFFFF:\n result = b'\\xde' + length.to_bytes(2, 'big')\n else:\n result = b'\\xdf' + length.to_bytes(4, 'big')\n for key, val in obj.items():\n result += _msgpack_pack(key) + _msgpack_pack(val)\n return result\n if isinstance(obj, (list, tuple)):\n length = len(obj)\n if length <= 0x0F:\n result = (0x90 | length).to_bytes(1, 'big')\n elif length <= 0xFFFF:\n result = b'\\xdc' + length.to_bytes(2, 'big')\n else:\n result = b'\\xdd' + length.to_bytes(4, 'big')\n for item in obj:\n result += _msgpack_pack(item)\n return result\n raise TypeError(f'Unsupported type for msgpack: {type(obj)}')\n\n\nRECORD_HEADER = b'\\xd4\\x72'\n\n\ndef _pack_v11_store_entry(\n algo: str,\n files: dict[str, dict[str, object]],\n manifest: dict[str, str] | None = None,\n) -> bytes:\n \"\"\"Encode a store v11 entry using msgpackr-compatible record extensions.\n\n msgpackr with ``useRecords: true, moreTypes: true`` decodes:\n\n - standard msgpack maps (0x80/0xde/0xdf) as JavaScript ``Map`` objects\n (iterable, supports ``for..of``)\n - record extensions (0x72) as plain objects (dot-notation access)\n\n The ``files`` field must be a Map so pnpm can iterate it with ``for..of``.\n Everything else must use record extensions so dot-notation works\n (e.g. ``pkgIndex.algo``, ``info.digest``, ``manifest.name``).\n \"\"\"\n\n def _record(obj: Mapping[str, object], struct_id: int) -> bytes:\n keys = list(obj.keys())\n result = RECORD_HEADER + struct_id.to_bytes(1, 'big')\n result += _msgpack_pack(keys)\n for k in keys:\n result += _msgpack_pack(obj[k])\n return result\n\n def _fixmap(length: int) -> bytes:\n if length <= 0x0F:\n return (0x80 | length).to_bytes(1, 'big')\n if length <= 0xFFFF:\n return b'\\xde' + length.to_bytes(2, 'big')\n return b'\\xdf' + length.to_bytes(4, 'big')\n\n # Pack file entries as records, build files map as standard msgpack map\n files_map_bytes = _fixmap(len(files))\n for fname, finfo in files.items():\n files_map_bytes += _msgpack_pack(fname) + _record(finfo, 0x41)\n\n # Outer store_entry as record (struct_id 0x40)\n store_entry_keys = ['algo', 'requiresBuild', 'files']\n if manifest is not None:\n store_entry_keys.append('manifest')\n\n # Record with fixed entries\n result = RECORD_HEADER + b'\\x40' + _msgpack_pack(store_entry_keys)\n result += _msgpack_pack(algo) # algo\n result += _msgpack_pack(False) # requiresBuild\n result += files_map_bytes # files (standard map \u2192 iterable Map in JS)\n\n if manifest is not None:\n result += _record(manifest, 0x42)\n return result\n\n\ndef populate_store(manifest_path: str, tarball_dir: str, store_dir: str) -> None:\n with open(manifest_path, encoding='utf-8') as f:\n manifest = json.load(f)\n\n store_version = manifest['store_version']\n packages = manifest['packages']\n\n index_db: sqlite3.Connection | None = None\n\n store = os.path.join(store_dir, store_version)\n os.makedirs(os.path.join(store, 'files'), exist_ok=True)\n if store_version == 'v11':\n index_db = sqlite3.connect(os.path.join(store, 'index.db'))\n index_db.execute('PRAGMA busy_timeout=5000')\n index_db.execute('PRAGMA journal_mode=WAL')\n index_db.execute('PRAGMA synchronous=NORMAL')\n index_db.execute('PRAGMA temp_store=MEMORY')\n index_db.execute(\n 'CREATE TABLE IF NOT EXISTS package_index ('\n ' key TEXT PRIMARY KEY,'\n ' data BLOB NOT NULL'\n ') WITHOUT ROWID'\n )\n else:\n os.makedirs(os.path.join(store, 'index'), exist_ok=True)\n\n now = int(time.time() * 1000)\n\n for tarball_name, info in packages.items():\n tarball_path = os.path.join(tarball_dir, tarball_name)\n if not os.path.isfile(tarball_path):\n raise FileNotFoundError(tarball_path)\n\n _process_tarball(\n tarball_path=tarball_path,\n pkg_name=info['name'],\n pkg_version=info['version'],\n integrity=info['integrity'],\n integrity_digest=info['integrity_digest'],\n integrity_algo=info['integrity_algo'],\n store=store,\n now=now,\n tarball_url=info.get('tarball_url'),\n store_version=store_version,\n index_db=index_db,\n )\n\n if index_db is not None:\n index_db.commit()\n index_db.close()\n\n\ndef _process_tarball(\n *,\n tarball_path: str,\n pkg_name: str,\n pkg_version: str,\n integrity: str,\n integrity_digest: str,\n integrity_algo: str,\n store: str,\n now: int,\n tarball_url: str | None = None,\n store_version: str = 'v3',\n index_db: sqlite3.Connection | None = None,\n) -> None:\n index_files: dict[str, dict[str, object]] = {}\n file_digests: dict[str, str] = {}\n real_pkg_name = pkg_name\n real_pkg_version = pkg_version\n\n with tarfile.open(tarball_path, 'r:gz') as tf:\n for member in tf.getmembers():\n if not member.isfile():\n continue\n fobj = tf.extractfile(member)\n if fobj is None:\n continue\n data = fobj.read()\n\n if member.name.endswith('package.json') and member.name.count('/') <= 1:\n with contextlib.suppress(ValueError, TypeError, UnicodeDecodeError):\n pkg_data = json.loads(data.decode('utf-8'))\n if isinstance(pkg_data, dict):\n if 'name' in pkg_data and isinstance(pkg_data['name'], str):\n real_pkg_name = pkg_data['name']\n if 'version' in pkg_data and isinstance(\n pkg_data['version'], str\n ):\n real_pkg_version = pkg_data['version']\n\n digest = hashlib.sha512(data).digest()\n file_hex = digest.hex()\n is_exec = bool(member.mode & 0o111)\n\n cas_dir = os.path.join(store, 'files', file_hex[:2])\n cas_name = file_hex[2:] + ('-exec' if is_exec else '')\n cas_path = os.path.join(cas_dir, cas_name)\n if not os.path.exists(cas_path):\n os.makedirs(cas_dir, exist_ok=True)\n with open(cas_path, 'wb') as out:\n out.write(data)\n if is_exec:\n os.chmod(cas_path, 0o755)\n\n rel_name = member.name\n if '/' in rel_name:\n rel_name = rel_name.split('/', 1)[1]\n\n b64 = base64.b64encode(digest).decode()\n index_files[rel_name] = {\n 'checkedAt': now,\n 'integrity': f'sha512-{b64}',\n 'mode': member.mode,\n 'size': len(data),\n }\n file_digests[rel_name] = file_hex\n\n if store_version == 'v11':\n assert index_db is not None\n # pnpm v11 store index keys use the raw package name (e.g. @scope/pkg),\n # not the filesystem-sanitized form (@scope+pkg), since keys are stored\n # in SQLite, not as filenames.\n raw_pkg_id = f'{pkg_name}@{pkg_version}'\n key = f'{integrity_algo}-{integrity}\\t{raw_pkg_id}'\n\n v11_files: dict[str, dict[str, object]] = {}\n for rel_name, finfo in index_files.items():\n checked_at = finfo['checkedAt']\n assert isinstance(checked_at, int)\n v11_files[rel_name] = {\n 'checkedAt': float(checked_at), # float64 avoids msgpackr BigInt\n 'digest': file_digests[rel_name],\n 'mode': finfo['mode'],\n 'size': finfo['size'],\n }\n\n manifest = None\n if real_pkg_name or real_pkg_version:\n manifest = {\n 'name': real_pkg_name,\n 'version': real_pkg_version,\n }\n\n entry_bytes = _pack_v11_store_entry(integrity_algo, v11_files, manifest)\n\n # It's currently not possible to fully determine which store key pnpm will use,\n # so we insert multiple keys to ensure pnpm can find the entry it wants.\n\n index_db.execute(\n 'INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)',\n (key, entry_bytes),\n )\n\n if tarball_url:\n url_key = f'{tarball_url}\\t{raw_pkg_id}'\n index_db.execute(\n 'INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)',\n (url_key, entry_bytes),\n )\n\n # pnpm looks up git-hosted tarballs (codeload.github.com,\n # bitbucket.org, gitlab.com) and tarballs without integrity by\n # {tarball_url}\\tbuilt(not-built) \u2014 see pickStoreIndexKey in @pnpm/store.index.\n pkgid_key = f'{tarball_url}\\t'\n index_db.execute(\n 'INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)',\n (pkgid_key + 'built', entry_bytes),\n )\n index_db.execute(\n 'INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)',\n (pkgid_key + 'not-built', entry_bytes),\n )\n else:\n pkg_id = _SANITIZE_RE.sub('+', f'{pkg_name}@{pkg_version}')\n\n index_data = {\n 'name': real_pkg_name,\n 'version': real_pkg_version,\n 'requiresBuild': False,\n 'files': index_files,\n }\n\n idx_prefix = integrity_digest[:2]\n idx_rest = integrity_digest[2:64]\n idx_dir = os.path.join(store, 'index', idx_prefix)\n os.makedirs(idx_dir, exist_ok=True)\n idx_path = os.path.join(idx_dir, f'{idx_rest}-{pkg_id}.json')\n with open(idx_path, 'w', encoding='utf-8') as out:\n json.dump(index_data, out)\n\n # For tarball-URL packages, also create an index entry keyed by the URL hash\n # this is how pnpm looks up tarball deps without integrity\n if tarball_url:\n if store_version == 'v3':\n url_hash = hashlib.sha256(tarball_url.encode()).hexdigest()\n url_idx_prefix = url_hash[:2]\n url_idx_rest = url_hash[2:64]\n url_idx_dir = os.path.join(store, 'index', url_idx_prefix)\n os.makedirs(url_idx_dir, exist_ok=True)\n url_idx_path = os.path.join(\n url_idx_dir, f'{url_idx_rest}-{pkg_id}.json'\n )\n with open(url_idx_path, 'w', encoding='utf-8') as out:\n json.dump(index_data, out)\n else:\n url_dir_name = re.sub(r'[:/]', '+', tarball_url)\n if (\n len(url_dir_name) > _MAX_LENGTH_WITHOUT_HASH\n or url_dir_name != url_dir_name.lower()\n ):\n url_dir_name = f'{url_dir_name[: _MAX_LENGTH_WITHOUT_HASH - 33]}_{hashlib.sha256(url_dir_name.encode()).hexdigest()[:32]}'\n url_idx_dir = os.path.join(store, url_dir_name)\n os.makedirs(url_idx_dir, exist_ok=True)\n url_idx_path = os.path.join(url_idx_dir, 'integrity.json')\n with open(url_idx_path, 'w', encoding='utf-8') as out:\n json.dump(index_data, out)\n\n\nif __name__ == '__main__':\n if len(sys.argv) != 4:\n print(\n f'Usage: {sys.argv[0]} ',\n file=sys.stderr,\n )\n sys.exit(1)\n populate_store(sys.argv[1], sys.argv[2], sys.argv[3])\n", + "contents": "from __future__ import annotations\n\nimport base64\nimport contextlib\nimport hashlib\nimport json\nimport os\nimport re\nimport sqlite3\nimport struct\nimport sys\nimport tarfile\nimport time\nfrom collections.abc import Mapping\n\n_SANITIZE_RE = re.compile(r'[\\\\/:*?\"<>|]')\n_MAX_LENGTH_WITHOUT_HASH = 120\n\n\ndef _msgpack_pack(obj: object) -> bytes:\n \"\"\"Minimal msgpack packer matching msgpackr with useRecords: true.\n\n msgpackr reserves the byte range 0x40-0x7F for record IDs when useRecords\n is enabled (pnpm's config). Standard msgpack uses 0x00-0x7F for positive\n fixints, but this packer caps them at 0x3F and emits a uint 8 prefix (0xcc)\n for 64-255 to avoid aliasing with record ID bytes.\n \"\"\"\n\n def _pack_int(val: int) -> bytes:\n # Positive fixint capped at 0x3F \u2014 0x40-0x7F are msgpackr record IDs\n if 0 <= val <= 0x3F:\n return val.to_bytes(1, 'big')\n if -32 <= val < 0:\n return val.to_bytes(1, 'big', signed=True)\n if val >= 0:\n if val <= 0xFF:\n return b'\\xcc' + val.to_bytes(1, 'big')\n if val <= 0xFFFF:\n return b'\\xcd' + val.to_bytes(2, 'big')\n if val <= 0xFFFFFFFF:\n return b'\\xce' + val.to_bytes(4, 'big')\n return b'\\xcf' + val.to_bytes(8, 'big')\n if val >= -0x80:\n return b'\\xd0' + val.to_bytes(1, 'big', signed=True)\n if val >= -0x8000:\n return b'\\xd1' + val.to_bytes(2, 'big', signed=True)\n if val >= -0x80000000:\n return b'\\xd2' + val.to_bytes(4, 'big', signed=True)\n return b'\\xd3' + val.to_bytes(8, 'big', signed=True)\n\n def _pack_str(val: str) -> bytes:\n data = val.encode('utf-8')\n length = len(data)\n if length <= 0x1F:\n return (0xA0 | length).to_bytes(1, 'big') + data\n if length <= 0xFF:\n return b'\\xd9' + length.to_bytes(1, 'big') + data\n if length <= 0xFFFF:\n return b'\\xda' + length.to_bytes(2, 'big') + data\n return b'\\xdb' + length.to_bytes(4, 'big') + data\n\n if obj is None:\n return b'\\xc0'\n if isinstance(obj, bool):\n return b'\\xc3' if obj else b'\\xc2'\n if isinstance(obj, int):\n return _pack_int(obj)\n if isinstance(obj, float):\n return b'\\xcb' + struct.pack('>d', obj)\n if isinstance(obj, str):\n return _pack_str(obj)\n if isinstance(obj, dict):\n length = len(obj)\n if length <= 0x0F:\n result = (0x80 | length).to_bytes(1, 'big')\n elif length <= 0xFFFF:\n result = b'\\xde' + length.to_bytes(2, 'big')\n else:\n result = b'\\xdf' + length.to_bytes(4, 'big')\n for key, val in obj.items():\n result += _msgpack_pack(key) + _msgpack_pack(val)\n return result\n if isinstance(obj, (list, tuple)):\n length = len(obj)\n if length <= 0x0F:\n result = (0x90 | length).to_bytes(1, 'big')\n elif length <= 0xFFFF:\n result = b'\\xdc' + length.to_bytes(2, 'big')\n else:\n result = b'\\xdd' + length.to_bytes(4, 'big')\n for item in obj:\n result += _msgpack_pack(item)\n return result\n raise TypeError(f'Unsupported type for msgpack: {type(obj)}')\n\n\nRECORD_HEADER = b'\\xd4\\x72'\n\n\ndef _pack_v11_store_entry(\n files: dict[str, dict[str, object]],\n manifest: dict[str, str] | None = None,\n) -> bytes:\n \"\"\"Encode a store v11 entry using msgpackr-compatible record extensions.\n\n msgpackr with ``useRecords: true, moreTypes: true`` decodes:\n\n - standard msgpack maps (0x80/0xde/0xdf) as JavaScript ``Map`` objects\n (iterable, supports ``for..of``)\n - record extensions (0x72) as plain objects (dot-notation access)\n\n The ``files`` field must be a Map so pnpm can iterate it with ``for..of``.\n Everything else must use record extensions so dot-notation works\n (e.g. ``pkgIndex.algo``, ``info.digest``, ``manifest.name``).\n \"\"\"\n\n def _record(obj: Mapping[str, object], struct_id: int) -> bytes:\n keys = list(obj.keys())\n result = RECORD_HEADER + struct_id.to_bytes(1, 'big')\n result += _msgpack_pack(keys)\n for k in keys:\n result += _msgpack_pack(obj[k])\n return result\n\n def _fixmap(length: int) -> bytes:\n if length <= 0x0F:\n return (0x80 | length).to_bytes(1, 'big')\n if length <= 0xFFFF:\n return b'\\xde' + length.to_bytes(2, 'big')\n return b'\\xdf' + length.to_bytes(4, 'big')\n\n # Pack file entries as records, build files map as standard msgpack map\n files_map_bytes = _fixmap(len(files))\n for fname, finfo in files.items():\n files_map_bytes += _msgpack_pack(fname) + _record(finfo, 0x41)\n\n # Outer store_entry as record (struct_id 0x40)\n store_entry_keys = ['algo', 'requiresBuild', 'files']\n if manifest is not None:\n store_entry_keys.append('manifest')\n\n # Record with fixed entries\n result = RECORD_HEADER + b'\\x40' + _msgpack_pack(store_entry_keys)\n # This is hardcoded to use sha512 per pnpm's behavior, see @pnpm/store.cafs/index and\n # file digest logic below\n result += _msgpack_pack('sha512') # algo\n result += _msgpack_pack(False) # requiresBuild\n result += files_map_bytes # files (standard map \u2192 iterable Map in JS)\n\n if manifest is not None:\n result += _record(manifest, 0x42)\n return result\n\n\ndef populate_store(manifest_path: str, tarball_dir: str, store_dir: str) -> None:\n with open(manifest_path, encoding='utf-8') as f:\n manifest = json.load(f)\n\n store_version = manifest['store_version']\n packages = manifest['packages']\n\n index_db: sqlite3.Connection | None = None\n\n store = os.path.join(store_dir, store_version)\n os.makedirs(os.path.join(store, 'files'), exist_ok=True)\n if store_version == 'v11':\n index_db = sqlite3.connect(os.path.join(store, 'index.db'))\n index_db.execute('PRAGMA busy_timeout=5000')\n index_db.execute('PRAGMA journal_mode=WAL')\n index_db.execute('PRAGMA synchronous=NORMAL')\n index_db.execute('PRAGMA temp_store=MEMORY')\n index_db.execute(\n 'CREATE TABLE IF NOT EXISTS package_index ('\n ' key TEXT PRIMARY KEY,'\n ' data BLOB NOT NULL'\n ') WITHOUT ROWID'\n )\n else:\n os.makedirs(os.path.join(store, 'index'), exist_ok=True)\n\n now = int(time.time() * 1000)\n\n for tarball_name, info in packages.items():\n tarball_path = os.path.join(tarball_dir, tarball_name)\n if not os.path.isfile(tarball_path):\n raise FileNotFoundError(tarball_path)\n\n _process_tarball(\n tarball_path=tarball_path,\n pkg_name=info['name'],\n pkg_version=info['version'],\n integrity=info['integrity'],\n integrity_digest=info['integrity_digest'],\n integrity_algo=info['integrity_algo'],\n store=store,\n now=now,\n tarball_url=info.get('tarball_url'),\n store_version=store_version,\n index_db=index_db,\n )\n\n if index_db is not None:\n index_db.commit()\n index_db.close()\n\n\ndef _process_tarball(\n *,\n tarball_path: str,\n pkg_name: str,\n pkg_version: str,\n integrity: str,\n integrity_digest: str,\n integrity_algo: str,\n store: str,\n now: int,\n tarball_url: str | None = None,\n store_version: str = 'v3',\n index_db: sqlite3.Connection | None = None,\n) -> None:\n index_files: dict[str, dict[str, object]] = {}\n file_digests: dict[str, str] = {}\n real_pkg_name = pkg_name\n real_pkg_version = pkg_version\n\n with tarfile.open(tarball_path, 'r:gz') as tf:\n for member in tf.getmembers():\n if not member.isfile():\n continue\n fobj = tf.extractfile(member)\n if fobj is None:\n continue\n data = fobj.read()\n\n if member.name.endswith('package.json') and member.name.count('/') <= 1:\n with contextlib.suppress(ValueError, TypeError, UnicodeDecodeError):\n pkg_data = json.loads(data.decode('utf-8'))\n if isinstance(pkg_data, dict):\n if 'name' in pkg_data and isinstance(pkg_data['name'], str):\n real_pkg_name = pkg_data['name']\n if 'version' in pkg_data and isinstance(\n pkg_data['version'], str\n ):\n real_pkg_version = pkg_data['version']\n\n digest = hashlib.sha512(data).digest()\n file_hex = digest.hex()\n is_exec = bool(member.mode & 0o111)\n\n cas_dir = os.path.join(store, 'files', file_hex[:2])\n cas_name = file_hex[2:] + ('-exec' if is_exec else '')\n cas_path = os.path.join(cas_dir, cas_name)\n if not os.path.exists(cas_path):\n os.makedirs(cas_dir, exist_ok=True)\n with open(cas_path, 'wb') as out:\n out.write(data)\n if is_exec:\n os.chmod(cas_path, 0o755)\n\n rel_name = member.name\n if '/' in rel_name:\n rel_name = rel_name.split('/', 1)[1]\n\n b64 = base64.b64encode(digest).decode()\n index_files[rel_name] = {\n 'checkedAt': now,\n 'integrity': f'sha512-{b64}',\n 'mode': member.mode,\n 'size': len(data),\n }\n file_digests[rel_name] = file_hex\n\n if store_version == 'v11':\n assert index_db is not None\n # pnpm v11 store index keys use the raw package name (e.g. @scope/pkg),\n # not the filesystem-sanitized form (@scope+pkg), since keys are stored\n # in SQLite, not as filenames.\n raw_pkg_id = f'{pkg_name}@{pkg_version}'\n key = f'{integrity_algo}-{integrity}\\t{raw_pkg_id}'\n\n v11_files: dict[str, dict[str, object]] = {}\n for rel_name, finfo in index_files.items():\n checked_at = finfo['checkedAt']\n assert isinstance(checked_at, int)\n v11_files[rel_name] = {\n 'checkedAt': float(checked_at), # float64 avoids msgpackr BigInt\n 'digest': file_digests[rel_name],\n 'mode': finfo['mode'],\n 'size': finfo['size'],\n }\n\n manifest = None\n if real_pkg_name or real_pkg_version:\n manifest = {\n 'name': real_pkg_name,\n 'version': real_pkg_version,\n }\n\n entry_bytes = _pack_v11_store_entry(v11_files, manifest)\n\n # It's currently not possible to fully determine which store key pnpm will use,\n # so we insert multiple keys to ensure pnpm can find the entry it wants.\n\n index_db.execute(\n 'INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)',\n (key, entry_bytes),\n )\n\n if tarball_url:\n url_key = f'{tarball_url}\\t{raw_pkg_id}'\n index_db.execute(\n 'INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)',\n (url_key, entry_bytes),\n )\n\n # pnpm looks up git-hosted tarballs (codeload.github.com,\n # bitbucket.org, gitlab.com) and tarballs without integrity by\n # {tarball_url}\\tbuilt(not-built) \u2014 see pickStoreIndexKey in @pnpm/store.index.\n pkgid_key = f'{tarball_url}\\t'\n index_db.execute(\n 'INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)',\n (pkgid_key + 'built', entry_bytes),\n )\n index_db.execute(\n 'INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)',\n (pkgid_key + 'not-built', entry_bytes),\n )\n else:\n pkg_id = _SANITIZE_RE.sub('+', f'{pkg_name}@{pkg_version}')\n\n index_data = {\n 'name': real_pkg_name,\n 'version': real_pkg_version,\n 'requiresBuild': False,\n 'files': index_files,\n }\n\n idx_prefix = integrity_digest[:2]\n idx_rest = integrity_digest[2:64]\n idx_dir = os.path.join(store, 'index', idx_prefix)\n os.makedirs(idx_dir, exist_ok=True)\n idx_path = os.path.join(idx_dir, f'{idx_rest}-{pkg_id}.json')\n with open(idx_path, 'w', encoding='utf-8') as out:\n json.dump(index_data, out)\n\n # For tarball-URL packages, also create an index entry keyed by the URL hash\n # this is how pnpm looks up tarball deps without integrity\n if tarball_url:\n if store_version == 'v3':\n url_hash = hashlib.sha256(tarball_url.encode()).hexdigest()\n url_idx_prefix = url_hash[:2]\n url_idx_rest = url_hash[2:64]\n url_idx_dir = os.path.join(store, 'index', url_idx_prefix)\n os.makedirs(url_idx_dir, exist_ok=True)\n url_idx_path = os.path.join(\n url_idx_dir, f'{url_idx_rest}-{pkg_id}.json'\n )\n with open(url_idx_path, 'w', encoding='utf-8') as out:\n json.dump(index_data, out)\n else:\n url_dir_name = re.sub(r'[:/]', '+', tarball_url)\n if (\n len(url_dir_name) > _MAX_LENGTH_WITHOUT_HASH\n or url_dir_name != url_dir_name.lower()\n ):\n url_dir_name = f'{url_dir_name[: _MAX_LENGTH_WITHOUT_HASH - 33]}_{hashlib.sha256(url_dir_name.encode()).hexdigest()[:32]}'\n url_idx_dir = os.path.join(store, url_dir_name)\n os.makedirs(url_idx_dir, exist_ok=True)\n url_idx_path = os.path.join(url_idx_dir, 'integrity.json')\n with open(url_idx_path, 'w', encoding='utf-8') as out:\n json.dump(index_data, out)\n\n\nif __name__ == '__main__':\n if len(sys.argv) != 4:\n print(\n f'Usage: {sys.argv[0]} ',\n file=sys.stderr,\n )\n sys.exit(1)\n populate_store(sys.argv[1], sys.argv[2], sys.argv[3])\n", "dest-filename": "populate_pnpm_store.py", "dest": "flatpak-node" }, diff --git a/io.github.block.Goose.yaml b/io.github.block.Goose.yaml index c13b7ff25e..2716a03f5c 100644 --- a/io.github.block.Goose.yaml +++ b/io.github.block.Goose.yaml @@ -39,13 +39,21 @@ modules: - install -Dm0644 cargo/config .cargo/config.toml - cargo --offline build --release -p goose-server - install -Dm0755 target/release/goosed ui/desktop/src/bin/goosed - - cd ui && corepack enable && pnpm config set store-dir /run/build/goose-desktop/flatpak-node/pnpm-store && pnpm install --offline --frozen-lockfile - - cd ui/desktop && pnpm run i18n:compile && ELECTRON_SKIP_BINARY_DOWNLOAD=1 ELECTRON_OVERRIDE_DIST_PATH=/usr/lib/sdk/node24/lib/node_modules/electron/dist npx electron-forge package --platform=linux - - mkdir -p /app/lib/goose - - cp -a ui/desktop/out/Goose-linux*/. /app/lib/goose/ - - install -Dm0755 flatpak/goose-desktop.sh /app/bin/goose-desktop - - install -Dm0644 flatpak/io.github.block.Goose.desktop /app/share/applications/io.github.block.Goose.desktop - - install -Dm0644 flatpak/io.github.block.Goose.metainfo.xml /app/share/metainfo/io.github.block.Goose.metainfo.xml + - mkdir -p /run/build/goose-desktop/pnpm-cli /run/build/goose-desktop/pnpm-bin && tar -xzf pnpm-10.30.3.tgz -C /run/build/goose-desktop/pnpm-cli && ln -sf /run/build/goose-desktop/pnpm-cli/package/bin/pnpm.cjs /run/build/goose-desktop/pnpm-bin/pnpm && ln -sf /run/build/goose-desktop/pnpm-cli/package/bin/pnpx.cjs /run/build/goose-desktop/pnpm-bin/pnpx + - export PATH="/run/build/goose-desktop/pnpm-bin:$PATH" && cd ui && pnpm config set store-dir /run/build/goose-desktop/flatpak-node/pnpm-store && pnpm install --offline --frozen-lockfile + - | + export PATH="/run/build/goose-desktop/pnpm-bin:$PATH" && \ + cd ui/desktop && \ + perl -0pi -e "s/const \{ resolve \} = require\('path'\);\n/const { resolve } = require('path');\nconst postPackage = require('..\/..\/forge-post-package');\n/" forge.config.ts && \ + perl -0pi -e "s/ icon: 'src\/images\/icon',\n/ icon: 'src\/images\/icon',\n electronZipDir: process.env.ELECTRON_ZIP_DIR,\n/" forge.config.ts && \ + perl -0pi -e "s/module\.exports = \{\n/module.exports = {\n hooks: { postPackage },\n/" forge.config.ts && \ + pnpm run i18n:compile && \ + ELECTRON_SKIP_BINARY_DOWNLOAD=1 \ + ELECTRON_ZIP_DIR=/run/build/goose-desktop/flatpak-node/cache/electron \ + pnpm exec electron-forge package --platform=linux + - install -Dm0755 goose-desktop.sh /app/bin/goose-desktop + - install -Dm0644 io.github.block.Goose.desktop /app/share/applications/io.github.block.Goose.desktop + - install -Dm0644 io.github.block.Goose.metainfo.xml /app/share/metainfo/io.github.block.Goose.metainfo.xml - install -Dm0644 ui/desktop/src/images/icon.svg /app/share/icons/hicolor/scalable/apps/io.github.block.Goose.svg - install -Dm0644 ui/desktop/src/images/icon-512.png /app/share/icons/hicolor/512x512/apps/io.github.block.Goose.png sources: @@ -56,5 +64,16 @@ modules: - type: file url: https://github.com/denoland/rusty_v8/releases/download/v145.0.0/librusty_v8_release_x86_64-unknown-linux-gnu.a.gz sha256: 7215753c0c78d141f752d7b993794bae07e18a1dfd466dcaa84fa64e76bacac1 + - type: file + url: https://registry.npmjs.org/pnpm/-/pnpm-10.30.3.tgz + sha512: c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017 + - type: file + path: flatpak/forge-post-package.js + - type: file + path: flatpak/goose-desktop.sh + - type: file + path: flatpak/io.github.block.Goose.desktop + - type: file + path: flatpak/io.github.block.Goose.metainfo.xml - flatpak/cargo-sources.json - flatpak/generated-sources.json diff --git a/ui/package.json b/ui/package.json index 361b903fb6..88bd19b664 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,5 +1,6 @@ { "private": true, + "packageManager": "pnpm@11.4.0", "workspaces": [ "acp", "text",