Compare commits

...

16 Commits

Author SHA1 Message Date
Gregory Schier
1127d7e3fa Use consistent release title format in generate-release-notes command 2026-02-11 17:38:13 -08:00
Gregory Schier
7d4d228236 Fix HTTP/2 requests failing with duplicate Content-Length (#391)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:11:35 -08:00
Gregory Schier
565e053ee8 Fix auth tab crash when template rendering fails (#392) 2026-02-11 14:59:17 -08:00
Gregory Schier
26aba6034f Move faker plugin back to external (plugins-external/faker) 2026-02-11 10:22:20 -08:00
Gregory Schier
9a1d613034 Fix RPM bundle path validation for metainfo file 2026-02-11 08:14:16 -08:00
Gregory Schier
3e4de7d3c4 Move build scripts to Flathub repo, keep release prep scripts here 2026-02-11 07:28:56 -08:00
Gregory Schier
b64b5ec0f8 Refresh Git dropdown data on open and fetch periodically
- Add refreshKey to useGit queries so dropdown data refreshes on open
- Convert fetchAll from mutation to query with 10-minute refetch interval
- Re-run status query after fetchAll completes via dataUpdatedAt key
- Use placeholderData to keep previous data during key changes
- Remove disabled state from Push, Pull, and Commit menu items
2026-02-11 07:20:53 -08:00
Gregory Schier
510d1c7d17 Remove Flatpak manifest (lives in Flathub repo) 2026-02-11 06:32:39 -08:00
Gregory Schier
ed13a62269 Use static desktop file and clean up manifest comments 2026-02-11 06:30:09 -08:00
Gregory Schier
935d613959 Move lockfile patch to standalone script 2026-02-10 23:35:14 -08:00
Gregory Schier
adeaaccc45 Add v2026.2.0 release to metainfo, simplify CI workflow
- Metainfo is managed upstream (updated before tagging)
- CI no longer modifies metainfo; just copies manifest and sources to Flathub
- Flathub manifest installs metainfo from git source
- Permissions reverted to read-only
2026-02-10 23:29:27 -08:00
Gregory Schier
d253093333 Revert "Simplify CI: metainfo releases only accumulate in Flathub repo"
This reverts commit f265b7a572.
2026-02-10 23:26:52 -08:00
Gregory Schier
f265b7a572 Simplify CI: metainfo releases only accumulate in Flathub repo
- Remove metainfo update from update-manifest.sh
- Remove CI step that committed metainfo back to app repo
- Revert permissions back to read-only
- CI now inserts release entry directly into Flathub repo's metainfo
2026-02-10 23:26:22 -08:00
Gregory Schier
68b2ff016f CI: rewrite metainfo paths for Flathub repo 2026-02-10 23:24:09 -08:00
Gregory Schier
a1c6295810 Clean up Flatpak manifest for v2026.2.0
- Update tag to v2026.2.0
- Use SKIP_WASM_BUILD env var instead of build-time package.json patch
- Install metainfo from git source (remove temporary type: file source)
- Fix fix-lockfile.mjs to skip workspace packages
- CI: commit metainfo releases back to app repo, bump permissions to write
2026-02-10 23:19:23 -08:00
Gregory Schier
76ee3fa61b Flatpak: build from source instead of repackaging debs (#389) 2026-02-10 23:05:33 -08:00
30 changed files with 417 additions and 236 deletions

View File

@@ -43,5 +43,7 @@ The skill generates markdown-formatted release notes following this structure:
After outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using:
```bash
gh release create <tag> --draft --prerelease --title "<tag>" --notes '<release notes>'
gh release create <tag> --draft --prerelease --title "Release <version>" --notes '<release notes>'
```
**IMPORTANT**: The release title format is "Release XXXX" where XXXX is the version WITHOUT the `v` prefix. For example, tag `v2026.2.1-beta.1` gets title "Release 2026.2.1-beta.1".

View File

@@ -16,9 +16,6 @@ jobs:
- name: Checkout app repo
uses: actions/checkout@v4
- name: Run update-manifest.sh
run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}"
- name: Checkout Flathub repo
uses: actions/checkout@v4
with:
@@ -26,11 +23,23 @@ jobs:
token: ${{ secrets.FLATHUB_TOKEN }}
path: flathub-repo
- name: Copy updated files to Flathub repo
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install source generators
run: |
cp flatpak/app.yaak.Yaak.yml flathub-repo/
cp LICENSE flathub-repo/
sed -i 's|path: \.\./LICENSE|path: LICENSE|' flathub-repo/app.yaak.Yaak.yml
pip install flatpak-node-generator tomlkit aiohttp
git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
- name: Run update-manifest.sh
run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}" flathub-repo
- name: Commit and push to Flathub
working-directory: flathub-repo

3
.gitignore vendored
View File

@@ -48,3 +48,6 @@ crates-tauri/yaak-app/tauri.worktree.conf.json
# Flatpak build artifacts
flatpak-repo/
.flatpak-builder/
flatpak/flatpak-builder-tools/
flatpak/cargo-sources.json
flatpak/node-sources.json

1
Cargo.lock generated
View File

@@ -8167,6 +8167,7 @@ dependencies = [
"cookie",
"flate2",
"futures-util",
"http-body",
"hyper-util",
"log",
"mime_guess",

View File

@@ -414,7 +414,7 @@ async fn execute_transaction<R: Runtime>(
sendable_request.body = Some(SendableBody::Bytes(bytes));
None
}
Some(SendableBody::Stream(stream)) => {
Some(SendableBody::Stream { data: stream, content_length }) => {
// Wrap stream with TeeReader to capture data as it's read
// Use unbounded channel to ensure all data is captured without blocking the HTTP request
let (body_chunk_tx, body_chunk_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
@@ -448,7 +448,7 @@ async fn execute_transaction<R: Runtime>(
None
};
sendable_request.body = Some(SendableBody::Stream(pinned));
sendable_request.body = Some(SendableBody::Stream { data: pinned, content_length });
handle
}
None => {

View File

@@ -1095,8 +1095,13 @@ async fn cmd_get_http_authentication_config<R: Runtime>(
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
let values_json: serde_json::Value = serde_json::to_value(&values)?;
let rendered_json =
render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;
let rendered_json = render_json_value(
values_json,
environment_chain,
&cb,
&RenderOptions::return_empty(),
)
.await?;
// Convert back to HashMap<String, JsonPrimitive>
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;

View File

@@ -46,13 +46,13 @@
"deb": {
"desktopTemplate": "./template.desktop",
"files": {
"usr/share/metainfo/app.yaak.Yaak.metainfo.xml": "../../flatpak/app.yaak.Yaak.metainfo.xml"
"/usr/share/metainfo/app.yaak.Yaak.metainfo.xml": "../../flatpak/app.yaak.Yaak.metainfo.xml"
}
},
"rpm": {
"desktopTemplate": "./template.desktop",
"files": {
"usr/share/metainfo/app.yaak.Yaak.metainfo.xml": "../../flatpak/app.yaak.Yaak.metainfo.xml"
"/usr/share/metainfo/app.yaak.Yaak.metainfo.xml": "../../flatpak/app.yaak.Yaak.metainfo.xml"
}
}
}

View File

@@ -32,22 +32,30 @@ export interface GitCallbacks {
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] });
export function useGit(dir: string, callbacks: GitCallbacks) {
export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
const mutations = useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
const fetchAll = useQuery<void, string>({
queryKey: ['git', 'fetch_all', dir, refreshKey],
queryFn: () => invoke('cmd_git_fetch_all', { dir }),
refetchInterval: 10 * 60_000,
});
return [
{
remotes: useQuery<GitRemote[], string>({
queryKey: ['git', 'remotes', dir],
queryKey: ['git', 'remotes', dir, refreshKey],
queryFn: () => getRemotes(dir),
placeholderData: (prev) => prev,
}),
log: useQuery<GitCommit[], string>({
queryKey: ['git', 'log', dir],
queryKey: ['git', 'log', dir, refreshKey],
queryFn: () => invoke('cmd_git_log', { dir }),
placeholderData: (prev) => prev,
}),
status: useQuery<GitStatusSummary, string>({
refetchOnMount: true,
queryKey: ['git', 'status', dir],
queryKey: ['git', 'status', dir, refreshKey, fetchAll.dataUpdatedAt],
queryFn: () => invoke('cmd_git_status', { dir }),
placeholderData: (prev) => prev,
}),
},
mutations,
@@ -152,10 +160,7 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
},
onSuccess,
}),
fetchAll: createFastMutation<void, string, void>({
mutationKey: ['git', 'fetch_all', dir],
mutationFn: () => invoke('cmd_git_fetch_all', { dir }),
}),
push: createFastMutation<PushResult, string, void>({
mutationKey: ['git', 'push', dir],
mutationFn: push,

View File

@@ -12,6 +12,7 @@ bytes = "1.11.1"
cookie = "0.18.1"
flate2 = "1"
futures-util = "0.3"
http-body = "1"
url = "2"
zstd = "0.13"
hyper-util = { version = "0.1.17", default-features = false, features = ["client-legacy"] }

View File

@@ -2,7 +2,9 @@ use crate::decompress::{ContentEncoding, streaming_decoder};
use crate::error::{Error, Result};
use crate::types::{SendableBody, SendableHttpRequest};
use async_trait::async_trait;
use bytes::Bytes;
use futures_util::StreamExt;
use http_body::{Body as HttpBody, Frame, SizeHint};
use reqwest::{Client, Method, Version};
use std::fmt::Display;
use std::pin::Pin;
@@ -413,10 +415,16 @@ impl HttpSender for ReqwestSender {
Some(SendableBody::Bytes(bytes)) => {
req_builder = req_builder.body(bytes);
}
Some(SendableBody::Stream(stream)) => {
// Convert AsyncRead stream to reqwest Body
let stream = tokio_util::io::ReaderStream::new(stream);
let body = reqwest::Body::wrap_stream(stream);
Some(SendableBody::Stream { data, content_length }) => {
// Convert AsyncRead stream to reqwest Body. If content length is
// known, wrap with a SizedBody so hyper can set Content-Length
// automatically (for both HTTP/1.1 and HTTP/2).
let stream = tokio_util::io::ReaderStream::new(data);
let body = if let Some(len) = content_length {
reqwest::Body::wrap(SizedBody::new(stream, len))
} else {
reqwest::Body::wrap_stream(stream)
};
req_builder = req_builder.body(body);
}
}
@@ -520,6 +528,51 @@ impl HttpSender for ReqwestSender {
}
}
/// A wrapper around a byte stream that reports a known content length via
/// `size_hint()`. This lets hyper set the `Content-Length` header
/// automatically based on the body size, without us having to add it as an
/// explicit header — which can cause duplicate `Content-Length` headers and
/// break HTTP/2.
struct SizedBody<S> {
stream: std::sync::Mutex<S>,
remaining: u64,
}
impl<S> SizedBody<S> {
fn new(stream: S, content_length: u64) -> Self {
Self { stream: std::sync::Mutex::new(stream), remaining: content_length }
}
}
impl<S> HttpBody for SizedBody<S>
where
S: futures_util::Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send + Unpin + 'static,
{
type Data = Bytes;
type Error = std::io::Error;
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<std::result::Result<Frame<Self::Data>, Self::Error>>> {
let this = self.get_mut();
let mut stream = this.stream.lock().unwrap();
match stream.poll_next_unpin(cx) {
Poll::Ready(Some(Ok(chunk))) => {
this.remaining = this.remaining.saturating_sub(chunk.len() as u64);
Poll::Ready(Some(Ok(Frame::data(chunk))))
}
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
fn size_hint(&self) -> SizeHint {
SizeHint::with_exact(self.remaining)
}
}
fn version_to_str(version: &Version) -> String {
match *version {
Version::HTTP_09 => "HTTP/0.9".to_string(),

View File

@@ -16,7 +16,13 @@ pub(crate) const MULTIPART_BOUNDARY: &str = "------YaakFormBoundary";
pub enum SendableBody {
Bytes(Bytes),
Stream(Pin<Box<dyn AsyncRead + Send + 'static>>),
Stream {
data: Pin<Box<dyn AsyncRead + Send + 'static>>,
/// Known content length for the stream, if available. This is used by
/// the sender to set the body size hint so that hyper can set
/// Content-Length automatically for both HTTP/1.1 and HTTP/2.
content_length: Option<u64>,
},
}
enum SendableBodyWithMeta {
@@ -31,7 +37,10 @@ impl From<SendableBodyWithMeta> for SendableBody {
fn from(value: SendableBodyWithMeta) -> Self {
match value {
SendableBodyWithMeta::Bytes(b) => SendableBody::Bytes(b),
SendableBodyWithMeta::Stream { data, .. } => SendableBody::Stream(data),
SendableBodyWithMeta::Stream { data, content_length } => SendableBody::Stream {
data,
content_length: content_length.map(|l| l as u64),
},
}
}
}
@@ -186,23 +195,11 @@ async fn build_body(
}
}
// Check if Transfer-Encoding: chunked is already set
let has_chunked_encoding = headers.iter().any(|h| {
h.0.to_lowercase() == "transfer-encoding" && h.1.to_lowercase().contains("chunked")
});
// Add a Content-Length header only if chunked encoding is not being used
if !has_chunked_encoding {
let content_length = match body {
Some(SendableBodyWithMeta::Bytes(ref bytes)) => Some(bytes.len()),
Some(SendableBodyWithMeta::Stream { content_length, .. }) => content_length,
None => None,
};
if let Some(cl) = content_length {
headers.push(("Content-Length".to_string(), cl.to_string()));
}
}
// NOTE: Content-Length is NOT set as an explicit header here. Instead, the
// body's content length is carried via SendableBody::Stream { content_length }
// and used by the sender to set the body size hint. This lets hyper handle
// Content-Length automatically for both HTTP/1.1 and HTTP/2, avoiding the
// duplicate Content-Length that breaks HTTP/2 servers.
Ok((body.map(|b| b.into()), headers))
}
@@ -928,7 +925,27 @@ mod tests {
}
#[tokio::test]
async fn test_no_content_length_with_chunked_encoding() -> Result<()> {
async fn test_no_content_length_header_added_by_build_body() -> Result<()> {
let mut body = BTreeMap::new();
body.insert("text".to_string(), json!("Hello, World!"));
let headers = vec![];
let (_, result_headers) =
build_body("POST", &Some("text/plain".to_string()), &body, headers).await?;
// Content-Length should NOT be set as an explicit header. Instead, the
// sender uses the body's size_hint to let hyper set it automatically,
// which works correctly for both HTTP/1.1 and HTTP/2.
let has_content_length =
result_headers.iter().any(|h| h.0.to_lowercase() == "content-length");
assert!(!has_content_length, "Content-Length should not be set as an explicit header");
Ok(())
}
#[tokio::test]
async fn test_chunked_encoding_header_preserved() -> Result<()> {
let mut body = BTreeMap::new();
body.insert("text".to_string(), json!("Hello, World!"));
@@ -938,11 +955,6 @@ mod tests {
let (_, result_headers) =
build_body("POST", &Some("text/plain".to_string()), &body, headers).await?;
// Verify that Content-Length is NOT present when Transfer-Encoding: chunked is set
let has_content_length =
result_headers.iter().any(|h| h.0.to_lowercase() == "content-length");
assert!(!has_content_length, "Content-Length should not be present with chunked encoding");
// Verify that the Transfer-Encoding header is still present
let has_chunked = result_headers.iter().any(|h| {
h.0.to_lowercase() == "transfer-encoding" && h.1.to_lowercase().contains("chunked")
@@ -951,31 +963,4 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn test_content_length_without_chunked_encoding() -> Result<()> {
let mut body = BTreeMap::new();
body.insert("text".to_string(), json!("Hello, World!"));
// Headers without Transfer-Encoding: chunked
let headers = vec![];
let (_, result_headers) =
build_body("POST", &Some("text/plain".to_string()), &body, headers).await?;
// Verify that Content-Length IS present when Transfer-Encoding: chunked is NOT set
let content_length_header =
result_headers.iter().find(|h| h.0.to_lowercase() == "content-length");
assert!(
content_length_header.is_some(),
"Content-Length should be present without chunked encoding"
);
assert_eq!(
content_length_header.unwrap().1,
"13",
"Content-Length should match the body size"
);
Ok(())
}
}

View File

@@ -0,0 +1,8 @@
const { execSync } = require('node:child_process');
if (process.env.SKIP_WASM_BUILD === '1') {
console.log('Skipping wasm-pack build (SKIP_WASM_BUILD=1)');
return;
}
execSync('wasm-pack build --target bundler', { stdio: 'inherit' });

View File

@@ -6,7 +6,7 @@
"scripts": {
"bootstrap": "npm run build",
"build": "run-s build:*",
"build:pack": "wasm-pack build --target bundler",
"build:pack": "node build-wasm.cjs",
"build:clean": "rimraf ./pkg/.gitignore"
},
"devDependencies": {

View File

@@ -81,6 +81,10 @@ impl RenderOptions {
pub fn throw() -> Self {
Self { error_behavior: RenderErrorBehavior::Throw }
}
pub fn return_empty() -> Self {
Self { error_behavior: RenderErrorBehavior::ReturnEmpty }
}
}
impl RenderErrorBehavior {

View File

@@ -52,6 +52,6 @@
</screenshots>
<releases>
<release version="2026.1.2" date="2026-02-10" />
<release version="2026.2.0" date="2026-02-10" />
</releases>
</component>

View File

@@ -1,61 +0,0 @@
id: app.yaak.Yaak
runtime: org.gnome.Platform
runtime-version: "49"
sdk: org.gnome.Sdk
command: yaak-app
rename-desktop-file: yaak.desktop
rename-icon: yaak-app
finish-args:
- --socket=wayland
- --socket=fallback-x11
- --share=ipc
- --device=dri
- --share=network
- --socket=pulseaudio # Preview audio responses
- --socket=ssh-auth # Git SSH remotes
- --socket=gpg-agent # Git commit signing
- --talk-name=org.freedesktop.secrets # Keyring for encryption
- --filesystem=home # Git repos, ~/.gitconfig, ~/.ssh, etc
modules:
- name: git
cleanup:
- /share
make-args:
- NO_PERL=1
- NO_TCLTK=1
make-install-args:
- INSTALL_SYMLINKS=1
- NO_PERL=1
- NO_TCLTK=1
sources:
- type: archive
url: https://www.kernel.org/pub/software/scm/git/git-2.48.1.tar.gz
sha256: 51b4d03b1e311ba673591210f94f24a4c5781453e1eb188822e3d9cdc04c2212
- name: yaak
buildsystem: simple
build-commands:
- ar -x yaak.deb
- tar -xf data.tar.gz
- mv usr/bin/* /app/bin
- mv usr/lib/* /app/lib
- mv usr/share/* /app/share
- install -Dm644 LICENSE /app/share/licenses/app.yaak.Yaak/LICENSE
sources:
- type: file
dest-filename: yaak.deb
url: https://github.com/mountain-loop/yaak/releases/download/v2026.1.2/yaak_2026.1.2_amd64.deb
sha256: "c4236b5bcf391e579dc79b71c3b5c58f6f9bfc6c175fc70426d0ca85799beba5"
only-arches:
- x86_64
- type: file
dest-filename: yaak.deb
url: https://github.com/mountain-loop/yaak/releases/download/v2026.1.2/yaak_2026.1.2_arm64.deb
sha256: "9ba9b7c9df56ffb9b801e40cb38685f1650cf7e2f9e85dad0ae3329f8e01ff6d"
only-arches:
- aarch64
- type: file
path: ../LICENSE

75
flatpak/fix-lockfile.mjs Normal file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env node
// Adds missing `resolved` and `integrity` fields to npm package-lock.json.
//
// npm sometimes omits these fields for nested dependencies inside workspace
// packages. This breaks offline installs and tools like flatpak-node-generator
// that need explicit tarball URLs for every package.
//
// Based on https://github.com/grant-dennison/npm-package-lock-add-resolved
// (MIT License, Copyright (c) 2024 Grant Dennison)
import { readFile, writeFile } from "node:fs/promises";
import { get } from "node:https";
const lockfilePath = process.argv[2] || "package-lock.json";
function fetchJson(url) {
return new Promise((resolve, reject) => {
get(url, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
if (res.statusCode === 200) {
resolve(JSON.parse(data));
} else {
reject(`${url} returned ${res.statusCode} ${res.statusMessage}`);
}
});
res.on("error", reject);
}).on("error", reject);
});
}
async function fillResolved(name, p) {
const version = p.version.replace(/^.*@/, "");
console.log(`Retrieving metadata for ${name}@${version}`);
const metadataUrl = `https://registry.npmjs.com/${name}/${version}`;
const metadata = await fetchJson(metadataUrl);
p.resolved = metadata.dist.tarball;
p.integrity = metadata.dist.integrity;
}
let changesMade = false;
async function fillAllResolved(packages) {
for (const packagePath in packages) {
if (packagePath === "") continue;
if (!packagePath.includes("node_modules/")) continue;
const p = packages[packagePath];
if (p.link) continue;
if (!p.inBundle && !p.bundled && (!p.resolved || !p.integrity)) {
const packageName =
p.name ||
/^npm:(.+?)@.+$/.exec(p.version)?.[1] ||
packagePath.replace(/^.*node_modules\/(?=.+?$)/, "");
await fillResolved(packageName, p);
changesMade = true;
}
}
}
const oldContents = await readFile(lockfilePath, "utf-8");
const packageLock = JSON.parse(oldContents);
await fillAllResolved(packageLock.packages ?? []);
if (changesMade) {
const newContents = JSON.stringify(packageLock, null, 2) + "\n";
await writeFile(lockfilePath, newContents);
console.log(`Updated ${lockfilePath}`);
} else {
console.log("No changes needed.");
}

48
flatpak/generate-sources.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# Generate offline dependency source files for Flatpak builds.
#
# Prerequisites:
# pip install flatpak-node-generator tomlkit aiohttp
# Clone https://github.com/flatpak/flatpak-builder-tools (for cargo generator)
#
# Usage:
# ./flatpak/generate-sources.sh <flathub-repo-path>
# ./flatpak/generate-sources.sh ../flathub-repo
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
if [ $# -lt 1 ]; then
echo "Usage: $0 <flathub-repo-path>"
echo "Example: $0 ../flathub-repo"
exit 1
fi
FLATHUB_REPO="$(cd "$1" && pwd)"
python3 "$SCRIPT_DIR/flatpak-builder-tools/cargo/flatpak-cargo-generator.py" \
-o "$FLATHUB_REPO/cargo-sources.json" "$REPO_ROOT/Cargo.lock"
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cp "$REPO_ROOT/package-lock.json" "$TMPDIR/package-lock.json"
cp "$REPO_ROOT/package.json" "$TMPDIR/package.json"
node "$SCRIPT_DIR/fix-lockfile.mjs" "$TMPDIR/package-lock.json"
node -e "
const fs = require('fs');
const p = process.argv[1];
const d = JSON.parse(fs.readFileSync(p, 'utf-8'));
for (const [name, info] of Object.entries(d.packages || {})) {
if (name && (info.link || !info.resolved)) delete d.packages[name];
}
fs.writeFileSync(p, JSON.stringify(d, null, 2));
" "$TMPDIR/package-lock.json"
flatpak-node-generator --no-requests-cache \
-o "$FLATHUB_REPO/node-sources.json" npm "$TMPDIR/package-lock.json"

View File

@@ -1,82 +1,86 @@
#!/usr/bin/env bash
#
# Update the Flatpak manifest with URLs and SHA256 hashes for a given release.
# Update the Flathub repo for a new release.
#
# Usage:
# ./flatpak/update-manifest.sh v2026.2.0
#
# This script:
# 1. Downloads the x86_64 and aarch64 .deb files from the GitHub release
# 2. Computes their SHA256 checksums
# 3. Updates the manifest YAML with the correct URLs and hashes
# 4. Updates the metainfo.xml with a new <release> entry
# ./flatpak/update-manifest.sh <version-tag> <flathub-repo-path>
# ./flatpak/update-manifest.sh v2026.2.0 ../flathub-repo
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MANIFEST="$SCRIPT_DIR/app.yaak.Yaak.yml"
METAINFO="$SCRIPT_DIR/app.yaak.Yaak.metainfo.xml"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
if [ $# -lt 1 ]; then
echo "Usage: $0 <version-tag>"
echo "Example: $0 v2026.2.0"
if [ $# -lt 2 ]; then
echo "Usage: $0 <version-tag> <flathub-repo-path>"
echo "Example: $0 v2026.2.0 ../flathub-repo"
exit 1
fi
VERSION_TAG="$1"
VERSION="${VERSION_TAG#v}"
FLATHUB_REPO="$(cd "$2" && pwd)"
MANIFEST="$FLATHUB_REPO/app.yaak.Yaak.yml"
METAINFO="$SCRIPT_DIR/app.yaak.Yaak.metainfo.xml"
# Only allow stable releases (skip beta, alpha, rc, etc.)
if [[ "$VERSION" == *-* ]]; then
echo "Skipping pre-release version '$VERSION_TAG' (only stable releases are published to Flathub)"
exit 0
fi
REPO="mountain-loop/yaak"
BASE_URL="https://github.com/$REPO/releases/download/$VERSION_TAG"
COMMIT=$(git ls-remote "https://github.com/$REPO.git" "refs/tags/$VERSION_TAG" | cut -f1)
DEB_AMD64="yaak_${VERSION}_amd64.deb"
DEB_ARM64="yaak_${VERSION}_arm64.deb"
if [ -z "$COMMIT" ]; then
echo "Error: Could not resolve commit for tag $VERSION_TAG"
exit 1
fi
echo "Tag: $VERSION_TAG"
echo "Commit: $COMMIT"
# Update git tag and commit in the manifest
sed -i "s|tag: v.*|tag: $VERSION_TAG|" "$MANIFEST"
sed -i "s|commit: .*|commit: $COMMIT|" "$MANIFEST"
echo "Updated manifest tag and commit."
# Regenerate offline dependency sources from the tagged lockfiles
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
echo "Downloading $DEB_AMD64..."
curl -fSL "$BASE_URL/$DEB_AMD64" -o "$TMPDIR/$DEB_AMD64"
SHA_AMD64=$(sha256sum "$TMPDIR/$DEB_AMD64" | cut -d' ' -f1)
echo " SHA256: $SHA_AMD64"
echo "Fetching lockfiles from $VERSION_TAG..."
curl -fsSL "https://raw.githubusercontent.com/$REPO/$VERSION_TAG/Cargo.lock" -o "$TMPDIR/Cargo.lock"
curl -fsSL "https://raw.githubusercontent.com/$REPO/$VERSION_TAG/package-lock.json" -o "$TMPDIR/package-lock.json"
curl -fsSL "https://raw.githubusercontent.com/$REPO/$VERSION_TAG/package.json" -o "$TMPDIR/package.json"
echo "Downloading $DEB_ARM64..."
curl -fSL "$BASE_URL/$DEB_ARM64" -o "$TMPDIR/$DEB_ARM64"
SHA_ARM64=$(sha256sum "$TMPDIR/$DEB_ARM64" | cut -d' ' -f1)
echo " SHA256: $SHA_ARM64"
echo "Generating cargo-sources.json..."
python3 "$SCRIPT_DIR/flatpak-builder-tools/cargo/flatpak-cargo-generator.py" \
-o "$FLATHUB_REPO/cargo-sources.json" "$TMPDIR/Cargo.lock"
echo ""
echo "Updating manifest: $MANIFEST"
echo "Generating node-sources.json..."
node "$SCRIPT_DIR/fix-lockfile.mjs" "$TMPDIR/package-lock.json"
# Update URLs by matching the arch-specific deb filename
sed -i "s|url: .*amd64\.deb|url: $BASE_URL/$DEB_AMD64|" "$MANIFEST"
sed -i "s|url: .*arm64\.deb|url: $BASE_URL/$DEB_ARM64|" "$MANIFEST"
node -e "
const fs = require('fs');
const p = process.argv[1];
const d = JSON.parse(fs.readFileSync(p, 'utf-8'));
for (const [name, info] of Object.entries(d.packages || {})) {
if (name && (info.link || !info.resolved)) delete d.packages[name];
}
fs.writeFileSync(p, JSON.stringify(d, null, 2));
" "$TMPDIR/package-lock.json"
# Update SHA256 hashes by finding the current ones and replacing
OLD_SHA_AMD64=$(grep -A2 "amd64\.deb" "$MANIFEST" | grep sha256 | sed 's/.*"\(.*\)"/\1/')
OLD_SHA_ARM64=$(grep -A2 "arm64\.deb" "$MANIFEST" | grep sha256 | sed 's/.*"\(.*\)"/\1/')
sed -i "s|$OLD_SHA_AMD64|$SHA_AMD64|" "$MANIFEST"
sed -i "s|$OLD_SHA_ARM64|$SHA_ARM64|" "$MANIFEST"
echo " Manifest updated."
echo "Updating metainfo: $METAINFO"
flatpak-node-generator --no-requests-cache \
-o "$FLATHUB_REPO/node-sources.json" npm "$TMPDIR/package-lock.json"
# Update metainfo with new release
TODAY=$(date +%Y-%m-%d)
# Insert new release entry after <releases>
sed -i "s| <releases>| <releases>\n <release version=\"$VERSION\" date=\"$TODAY\" />|" "$METAINFO"
echo " Metainfo updated."
echo "Updated metainfo with release $VERSION."
echo ""
echo "Done! Review the changes:"
echo " $MANIFEST"
echo " $METAINFO"
echo " $FLATHUB_REPO/cargo-sources.json"
echo " $FLATHUB_REPO/node-sources.json"

70
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"plugins-external/mcp-server",
"plugins/template-function-faker",
"plugins-external/faker",
"plugins-external/httpsnippet",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
@@ -4154,7 +4154,7 @@
"link": true
},
"node_modules/@yaak/faker": {
"resolved": "plugins/template-function-faker",
"resolved": "plugins-external/faker",
"link": true
},
"node_modules/@yaak/filter-jsonpath": {
@@ -15957,6 +15957,33 @@
"undici-types": "~7.16.0"
}
},
"plugins-external/faker": {
"name": "@yaak/faker",
"version": "1.1.1",
"dependencies": {
"@faker-js/faker": "^10.1.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
"typescript": "^5.9.3"
}
},
"plugins-external/faker/node_modules/@faker-js/faker": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.3.0.tgz",
"integrity": "sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
"npm": ">=10"
}
},
"plugins-external/httpsnippet": {
"name": "@yaak/httpsnippet",
"version": "1.0.3",
@@ -16062,18 +16089,6 @@
"name": "@yaak/auth-oauth2",
"version": "0.1.0"
},
"plugins/faker": {
"name": "@yaak/faker",
"version": "1.1.1",
"extraneous": true,
"dependencies": {
"@faker-js/faker": "^10.1.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
"typescript": "^5.9.3"
}
},
"plugins/filter-jsonpath": {
"name": "@yaak/filter-jsonpath",
"version": "0.1.0",
@@ -16151,33 +16166,6 @@
"name": "@yaak/template-function-encode",
"version": "0.1.0"
},
"plugins/template-function-faker": {
"name": "@yaak/faker",
"version": "1.1.1",
"dependencies": {
"@faker-js/faker": "^10.1.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
"typescript": "^5.9.3"
}
},
"plugins/template-function-faker/node_modules/@faker-js/faker": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.3.0.tgz",
"integrity": "sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
"npm": ">=10"
}
},
"plugins/template-function-fs": {
"name": "@yaak/template-function-fs",
"version": "0.1.0"

View File

@@ -11,7 +11,7 @@
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"plugins-external/mcp-server",
"plugins/template-function-faker",
"plugins-external/faker",
"plugins-external/httpsnippet",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",

View File

@@ -7,7 +7,7 @@
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/template-function-faker"
"directory": "plugins-external/faker"
},
"scripts": {
"build": "yaakcli build",

View File

@@ -1,4 +1,6 @@
const path = require('node:path');
const crypto = require('node:crypto');
const fs = require('node:fs');
const decompress = require('decompress');
const Downloader = require('nodejs-file-downloader');
const { rmSync, cpSync, mkdirSync, existsSync } = require('node:fs');
@@ -41,6 +43,15 @@ const DST_BIN_MAP = {
[WIN_ARM]: 'yaaknode.exe',
};
const SHA256_MAP = {
[MAC_ARM]: 'b05aa3a66efe680023f930bd5af3fdbbd542794da5644ca2ad711d68cbd4dc35',
[MAC_X64]: '096081b6d6fcdd3f5ba0f5f1d44a47e83037ad2e78eada26671c252fe64dd111',
[LNX_ARM]: '0dc93ec5c798b0d347f068db6d205d03dea9a71765e6a53922b682b91265d71f',
[LNX_X64]: '58a5ff5cc8f2200e458bea22e329d5c1994aa1b111d499ca46ec2411d58239ca',
[WIN_X64]: '5355ae6d7c49eddcfde7d34ac3486820600a831bf81dc3bdca5c8db6a9bb0e76',
[WIN_ARM]: 'ce9ee4e547ebdff355beb48e309b166c24df6be0291c9eaf103ce15f3de9e5b4',
};
const key = `${process.platform}_${process.env.YAAK_TARGET_ARCH ?? process.arch}`;
const destDir = path.join(__dirname, `..`, 'crates-tauri', 'yaak-app', 'vendored', 'node');
@@ -68,6 +79,15 @@ rmSync(tmpDir, { recursive: true, force: true });
timeout: 1000 * 60 * 2,
}).download();
// Verify SHA256
const expectedHash = SHA256_MAP[key];
const fileBuffer = fs.readFileSync(filePath);
const actualHash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
if (actualHash !== expectedHash) {
throw new Error(`SHA256 mismatch for ${path.basename(filePath)}\n expected: ${expectedHash}\n actual: ${actualHash}`);
}
console.log('SHA256 verified:', actualHash);
// Decompress to the same directory
await decompress(filePath, tmpDir, {});

View File

@@ -1,3 +1,5 @@
const crypto = require('node:crypto');
const fs = require('node:fs');
const decompress = require('decompress');
const Downloader = require('nodejs-file-downloader');
const path = require('node:path');
@@ -41,6 +43,15 @@ const DST_BIN_MAP = {
[WIN_ARM]: 'yaakprotoc.exe',
};
const SHA256_MAP = {
[MAC_ARM]: 'db7e66ff7f9080614d0f5505a6b0ac488cf89a15621b6a361672d1332ec2e14e',
[MAC_X64]: 'e20b5f930e886da85e7402776a4959efb1ed60c57e72794bcade765e67abaa82',
[LNX_ARM]: '6018147740548e0e0f764408c87f4cd040e6e1c1203e13aeacaf811892b604f3',
[LNX_X64]: 'f3340e28a83d1c637d8bafdeed92b9f7db6a384c26bca880a6e5217b40a4328b',
[WIN_X64]: 'd7a207fb6eec0e4b1b6613be3b7d11905375b6fd1147a071116eb8e9f24ac53b',
[WIN_ARM]: 'd7a207fb6eec0e4b1b6613be3b7d11905375b6fd1147a071116eb8e9f24ac53b',
};
const dstDir = path.join(__dirname, `..`, 'crates-tauri', 'yaak-app', 'vendored', 'protoc');
const key = `${process.platform}_${process.env.YAAK_TARGET_ARCH ?? process.arch}`;
console.log(`Vendoring protoc ${VERSION} for ${key}`);
@@ -63,6 +74,15 @@ mkdirSync(dstDir, { recursive: true });
// Download GitHub release artifact
const { filePath } = await new Downloader({ url, directory: tmpDir }).download();
// Verify SHA256
const expectedHash = SHA256_MAP[key];
const fileBuffer = fs.readFileSync(filePath);
const actualHash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
if (actualHash !== expectedHash) {
throw new Error(`SHA256 mismatch for ${path.basename(filePath)}\n expected: ${expectedHash}\n actual: ${actualHash}`);
}
console.log('SHA256 verified:', actualHash);
// Decompress to the same directory
await decompress(filePath, tmpDir, {});

View File

@@ -7,6 +7,7 @@ import { forwardRef } from 'react';
import { openWorkspaceSettings } from '../../commands/openWorkspaceSettings';
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../../hooks/useActiveWorkspace';
import { useKeyValue } from '../../hooks/useKeyValue';
import { useRandomKey } from '../../hooks/useRandomKey';
import { sync } from '../../init/sync';
import { showConfirm, showConfirmDelete } from '../../lib/confirm';
import { showDialog } from '../../lib/dialog';
@@ -36,6 +37,7 @@ export function GitDropdown() {
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const workspace = useAtomValue(activeWorkspaceAtom);
const [refreshKey, regenerateKey] = useRandomKey();
const [
{ status, log },
{
@@ -43,7 +45,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
deleteBranch,
deleteRemoteBranch,
renameBranch,
fetchAll,
mergeBranch,
push,
pull,
@@ -51,7 +52,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
resetChanges,
init,
},
] = useGit(syncDir, gitCallbacks(syncDir));
] = useGit(syncDir, gitCallbacks(syncDir), refreshKey);
const localBranches = status.data?.localBranches ?? [];
const remoteBranches = status.data?.remoteBranches ?? [];
@@ -172,7 +173,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
{ type: 'separator' },
{
label: 'Push',
disabled: !hasRemotes || ahead === 0,
hidden: !hasRemotes,
leftSlot: <Icon icon="arrow_up_from_line" />,
waitForOnSelect: true,
async onSelect() {
@@ -191,7 +192,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
},
{
label: 'Pull',
disabled: !hasRemotes || behind === 0,
hidden: !hasRemotes,
leftSlot: <Icon icon="arrow_down_to_line" />,
waitForOnSelect: true,
async onSelect() {
@@ -210,7 +211,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
},
{
label: 'Commit...',
disabled: !hasChanges,
leftSlot: <Icon icon="git_commit_vertical" />,
onSelect() {
showDialog({
@@ -502,15 +503,25 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
];
return (
<Dropdown fullWidth items={items} onOpen={fetchAll.mutate}>
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
<GitMenuButton>
<InlineCode className="flex items-center gap-1">
<Icon icon="git_branch" size="xs" className="opacity-50" />
{currentBranch}
</InlineCode>
<div className="flex items-center gap-1.5">
{ahead > 0 && <span className="text-xs flex items-center gap-0.5"><span className="text-primary"></span>{ahead}</span>}
{behind > 0 && <span className="text-xs flex items-center gap-0.5"><span className="text-info"></span>{behind}</span>}
{ahead > 0 && (
<span className="text-xs flex items-center gap-0.5">
<span className="text-primary"></span>
{ahead}
</span>
)}
{behind > 0 && (
<span className="text-xs flex items-center gap-0.5">
<span className="text-info"></span>
{behind}
</span>
)}
</div>
</GitMenuButton>
</Dropdown>