Compare commits

...
15 Commits
Author SHA1 Message Date
Twyco fa8a0cc512 Merge pull request 'Add permanent macOS app download' (#164) from feature/macos-download-app into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m36s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m3s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m14s
CI/CD / test_and_check_app (push) Successful in 7m13s
CI/CD / preconditions (push) Successful in 2s
CI/CD / Publish immutable image (push) Successful in 14s
Reviewed-on: #164
2026-09-28 07:22:09 +00:00
Twyco d18fffe665 feat: add stable macOS app download to website
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m3s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m41s
CI/CD / test_and_check_app (pull_request) Successful in 6m53s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-28 09:12:37 +02:00
Twyco aece653970 update macos to v0.2.0
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 59s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m17s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m33s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 3m7s
2026-09-28 08:18:22 +02:00
Twyco 2b1cf6ec13 Merge pull request 'KAN-65: Prepare macOS 0.1.5 build 5' (#163) from feature/KAN-65-release-0-1-5 into main
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m14s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 5s
Reviewed-on: #163
2026-09-28 06:08:54 +00:00
Twyco 38355c9507 chore(KAN-65): prepare macOS 0.1.5 build 5
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m0s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m34s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 54s
2026-09-28 08:06:55 +02:00
Twyco d0c5d65f0e Merge pull request 'KAN-65: Fix update menu and rebuild failed macOS releases' (#162) from feature/KAN-65-remove-release-resume into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 6s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
Reviewed-on: #162
2026-09-28 06:04:03 +00:00
Twyco 4f9bf2dec4 fix(KAN-65): synchronize update menu and remove dock badge
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m37s
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m4s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 1m38s
2026-09-28 08:01:35 +02:00
Twyco 4ee047311f fix(KAN-65): allow rebuilding unpublished macOS releases
CI/CD / test_and_check_app (pull_request) Successful in 6m53s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / preconditions (pull_request) Successful in 2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m32s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m5s
2026-09-28 07:51:56 +02:00
Twyco 41b6dc8f10 refactor(KAN-65): remove release resume mode 2026-09-28 07:43:31 +02:00
Twyco 3d5ee223e8 Merge pull request 'KAN-65: Fix Hetzner feed ETags and resume published releases' (#161) from fix/KAN-65-hetzner-feed-etag into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 42s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Resume verified release publication (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 3m0s
Reviewed-on: #161
2026-09-28 05:27:36 +00:00
Twyco 9dbb491dc9 fix(KAN-65): resume release publication with CI credentials
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 11s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 0s
2026-09-28 07:27:05 +02:00
Twyco 40b66bf7ce fix(KAN-65): normalize Hetzner conditional write ETags
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m5s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m34s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5m16s
2026-09-28 07:24:12 +02:00
Twyco dc9f400f34 fix(KAN-65): recover verified S3 writes from empty error responses
CI/CD / test_and_check_app (push) Successful in 6m31s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 1m18s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 4m57s
CI/CD / preconditions (push) Successful in 2s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m35s
Native JavaScript checks / Native launcher and release tests (push) Successful in 58s
2026-09-27 23:05:35 +02:00
Twyco 48f5fc410b Merge pull request 'KAN-65: Show the actual macOS build number in About and updates' (#160) from fix/KAN-65-about-build-number into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 11s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 4m21s
Reviewed-on: #160
2026-09-27 20:49:33 +00:00
Twyco f6725ac025 fix(KAN-65): show the installed macOS build number
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 2m0s
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 58s
CI/CD / test_and_check_app (pull_request) Successful in 6m28s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-27 22:42:01 +02:00
21 changed files with 1097 additions and 101 deletions
+3 -1
View File
@@ -17,6 +17,8 @@ permissions:
contents: read
concurrency:
# Partial release files may be replaced until the feed is promoted.
# Keep every publisher in this group to prevent overlapping rebuilds.
group: macos-release
cancel-in-progress: false
@@ -198,7 +200,7 @@ jobs:
id: package
run: node native/macos-ci.mjs package
- name: Publish immutable files and verify public Hetzner downloads
- name: Upload release files and promote the verified update feed
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "kanbangit-native",
"version": "0.1.2",
"version": "0.2.1",
"private": true,
"type": "module",
"scripts": {
@@ -24,5 +24,5 @@
"devDependencies": {
"@tauri-apps/cli": "2.11.4"
},
"releaseNotes": "macOS updates can now be checked automatically or from the KanbanGit menu. Install releases from a drag-and-drop DMG. About KanbanGit shows the app version and build. Updates preserve your server settings and stored sessions and restart only after confirmation."
"releaseNotes": "Add the stable Mac download for the KanbanGit website."
}
+161 -29
View File
@@ -63,6 +63,31 @@ export function storageConfiguration(env = process.env) {
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
export function conditionalEtag(etag) {
const value =
typeof etag === 'string' && etag.startsWith('"') && etag.endsWith('"')
? etag.slice(1, -1)
: etag;
if (
typeof value !== 'string' ||
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
) {
throw new Error(
'Cannot atomically replace an object without a valid S3 ETag.',
);
}
// Hetzner returns quoted ETags on GET but compares bare values on conditional PUT.
return value;
}
function requiresExistingObjectCheck(error) {
// AWS CLI can crash while formatting Hetzner's empty error Message.
// This only permits a readback; content checks and conditional writes still apply.
return /\(PreconditionFailed\)|\(412\)|argument of type 'NoneType' is not (?:a container or )?iterable/.test(
error.message,
);
}
export async function publishMacos({
env = process.env,
directory,
@@ -116,13 +141,21 @@ export async function publishMacos({
's3api',
];
function s3(operation, args) {
return run('aws', [
...common,
operation,
'--bucket',
storage.bucket,
...args,
]);
try {
return run('aws', [
...common,
operation,
'--bucket',
storage.bucket,
...args,
]);
} catch (error) {
const keyIndex = args.indexOf('--key');
const key = keyIndex >= 0 ? ` ${args[keyIndex + 1]}` : '';
throw new Error(`S3 ${operation}${key}: ${error.message}`, {
cause: error,
});
}
}
const versioningOutput = s3('get-bucket-versioning', [
'--output',
@@ -152,12 +185,15 @@ export async function publishMacos({
const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
const latestPrefix = `${storage.prefix}/${metadata.target}`;
const latestKey = `${latestPrefix}/latest.json`;
async function verifyPublic(name, contents, prefix = publicPrefix) {
for (let attempt = 1; attempt <= 5; attempt++) {
let response;
try {
response = await fetchPublic(`${prefix}/${name}`, {
redirect: 'error',
cache: 'no-store',
signal: AbortSignal.timeout(60000),
});
} catch (error) {
@@ -192,36 +228,90 @@ export async function publishMacos({
await wait(2000);
}
}
function putImmutable(name, contents, contentType) {
function assertUnpublishedRelease(allowSameVersion = false) {
const file = join(staging, 'published-latest.json');
try {
s3('get-object', ['--key', latestKey, file]);
} catch (error) {
if (/\(NoSuchKey\)/.test(error.message)) {
return;
}
if (requiresExistingObjectCheck(error)) {
// A broken CLI error message cannot distinguish missing from denied.
// Only an authenticated, explicitly empty listing proves absence.
const listing = JSON.parse(
s3('list-objects-v2', [
'--prefix',
latestKey,
'--max-keys',
'1',
'--output',
'json',
]),
);
if (
listing?.KeyCount === 0 &&
listing.IsTruncated === false &&
Array.isArray(listing.Contents ?? []) &&
(listing.Contents ?? []).length === 0
) {
return;
}
}
throw error;
}
const published = JSON.parse(readFileSync(file, 'utf8'));
const comparison = compareVersions(metadata.version, published.version);
if (allowSameVersion && comparison < 0) {
throw new Error('Refusing to downgrade the stable download.');
}
if (comparison < 0 || (!allowSameVersion && comparison === 0)) {
throw new Error(
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
);
}
}
// The macos-release concurrency group serializes the entire publication.
// Never run independent publishers against the same release destination.
function putReleaseFile(name, contents, contentType) {
const file = join(staging, name);
writeFileSync(file, contents);
try {
function put(condition) {
s3('put-object', [
'--key',
`${keyPrefix}/${name}`,
'--body',
file,
'--if-none-match',
'*',
...condition,
'--content-type',
contentType,
'--cache-control',
'public, max-age=31536000, immutable',
'no-cache, max-age=0, must-revalidate',
'--content-md5',
createHash('md5').update(contents).digest('base64'),
'--metadata',
`sha256=${sha256(contents)}`,
]);
}
try {
put(['--if-none-match', '*']);
} catch (error) {
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) {
if (!requiresExistingObjectCheck(error)) {
throw error;
}
const existing = join(staging, `existing-${name}`);
s3('get-object', ['--key', `${keyPrefix}/${name}`, existing]);
const response = JSON.parse(
s3('get-object', [
'--key',
`${keyPrefix}/${name}`,
'--output',
'json',
existing,
]),
);
if (sha256(readFileSync(existing)) !== sha256(contents)) {
throw new Error(
`Published ${name} differs; bump the app version. Existing objects were not overwritten.`,
);
assertUnpublishedRelease();
put(['--if-match', conditionalEtag(response.ETag)]);
}
}
}
@@ -246,7 +336,7 @@ export async function publishMacos({
],
];
for (const [name, contents, type] of files) {
putImmutable(name, contents, type);
putReleaseFile(name, contents, type);
await verifyPublic(name, contents);
}
// Completion metadata is written only after every public artifact matches.
@@ -263,7 +353,7 @@ export async function publishMacos({
2,
) + '\n',
);
putImmutable('release.json', manifest, 'application/json');
putReleaseFile('release.json', manifest, 'application/json');
await verifyPublic('release.json', manifest);
const latest = Buffer.from(
JSON.stringify(
@@ -277,10 +367,57 @@ export async function publishMacos({
2,
) + '\n',
);
putImmutable('update.json', latest, 'application/json');
putReleaseFile('update.json', latest, 'application/json');
await verifyPublic('update.json', latest);
const latestPrefix = `${storage.prefix}/${metadata.target}`;
const latestKey = `${latestPrefix}/latest.json`;
// An older release must not populate an absent download alias either.
assertUnpublishedRelease(true);
const downloadName = 'KanbanGit.dmg';
const downloadKey = `${latestPrefix}/${downloadName}`;
const downloadFile = join(staging, downloadName);
writeFileSync(downloadFile, archive);
function publishDownload(condition) {
s3('put-object', [
'--key',
downloadKey,
'--body',
downloadFile,
...condition,
'--content-type',
'application/x-apple-diskimage',
'--cache-control',
'no-cache, max-age=0, must-revalidate',
'--content-md5',
createHash('md5').update(archive).digest('base64'),
'--metadata',
`sha256=${checksum}`,
]);
}
try {
publishDownload(['--if-none-match', '*']);
} catch (error) {
if (!requiresExistingObjectCheck(error)) {
throw error;
}
const previousFile = join(staging, 'previous-download.dmg');
const response = JSON.parse(
s3('get-object', [
'--key',
downloadKey,
'--output',
'json',
previousFile,
]),
);
if (sha256(readFileSync(previousFile)) !== checksum) {
assertUnpublishedRelease();
publishDownload(['--if-match', conditionalEtag(response.ETag)]);
}
}
await verifyPublic(
downloadName,
archive,
`${storage.publicBase}/${latestPrefix}`,
);
const latestFile = join(staging, 'latest.json');
writeFileSync(latestFile, latest);
function publishLatest(condition) {
@@ -301,7 +438,7 @@ export async function publishMacos({
try {
publishLatest(['--if-none-match', '*']);
} catch (error) {
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) {
if (!requiresExistingObjectCheck(error)) {
throw error;
}
const previousFile = join(staging, 'previous-latest.json');
@@ -330,12 +467,7 @@ export async function publishMacos({
);
}
} else {
if (!response.ETag) {
throw new Error(
'Cannot atomically update the feed without its ETag.',
);
}
publishLatest(['--if-match', response.ETag]);
publishLatest(['--if-match', conditionalEtag(response.ETag)]);
}
}
await verifyPublic(
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]]
name = "kanbangit-native"
version = "0.1.2"
version = "0.2.1"
dependencies = [
"semver",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kanbangit-native"
version = "0.1.2"
version = "0.2.1"
description = "KanbanGit native application"
edition = "2021"
publish = false
+24 -7
View File
@@ -285,6 +285,16 @@ pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
tauri::generate_context!()
}
fn about_metadata(version: String) -> AboutMetadata<'static> {
AboutMetadata {
name: Some(en::APP_NAME.into()),
version: Some(version),
#[cfg(target_os = "macos")]
short_version: Some(crate::metadata::build_number().into()),
..Default::default()
}
}
pub fn builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default().setup(|app| {
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
@@ -298,15 +308,10 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
}));
let menu = MenuBuilder::new(app)
.item(
&SubmenuBuilder::new(app, en::APP_NAME)
&SubmenuBuilder::with_id(app, "app-menu", en::APP_NAME)
.about_with_text(
en::ABOUT,
Some(AboutMetadata {
name: Some(en::APP_NAME.into()),
short_version: Some(app.package_info().version.to_string()),
version: app.config().bundle.macos.bundle_version.clone(),
..Default::default()
}),
Some(about_metadata(app.package_info().version.to_string())),
)
.text("check-updates", en::CHECK_UPDATES)
.item(&MenuItem::with_id(
@@ -439,6 +444,18 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
mod tests {
use super::*;
#[cfg(target_os = "macos")]
#[test]
fn about_shows_app_version_followed_by_the_actual_build_number() {
let metadata = about_metadata("9.8.7".into());
assert_eq!(metadata.version.as_deref(), Some("9.8.7"));
assert_eq!(
metadata.short_version.as_deref(),
Some(crate::metadata::build_number())
);
assert_ne!(metadata.version, metadata.short_version);
}
#[test]
fn external_links_accept_only_web_urls_without_credentials() {
for input in [
+5
View File
@@ -6,6 +6,8 @@ mod desktop;
mod ios;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod locale;
#[cfg(target_os = "macos")]
mod metadata;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod server;
#[cfg(target_os = "macos")]
@@ -18,6 +20,9 @@ pub use desktop::{builder, context};
#[cfg(target_os = "ios")]
pub use ios::{builder, context};
#[cfg(all(target_os = "macos", feature = "smoke-test"))]
pub use updates::refresh_menu as refresh_update_menu_for_smoke_test;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
builder().run(context()).expect("Could not start KanbanGit");
-1
View File
@@ -2,7 +2,6 @@ pub mod en {
pub const ABOUT: &str = "About KanbanGit";
pub const CHECK_UPDATES: &str = "Check For Updates…";
pub const INSTALL_UPDATE: &str = "Install Update…";
pub const UPDATE_BADGE: &str = "Update";
pub const UPDATES: &str = "KanbanGit Updates";
pub const APP_NAME: &str = "KanbanGit";
pub const SERVER_SETTINGS: &str = "Server settings…";
+69
View File
@@ -0,0 +1,69 @@
use std::{path::Path, sync::LazyLock};
fn build_from_executable(executable: &Path) -> Option<String> {
let macos = executable.parent()?;
let contents = macos.parent()?;
if macos.file_name()? != "MacOS" || contents.file_name()? != "Contents" {
return None;
}
let output = std::process::Command::new("/usr/bin/plutil")
.args(["-extract", "CFBundleVersion", "raw", "-o", "-"])
.arg(contents.join("Info.plist"))
.output()
.ok()?;
if !output.status.success() {
return Some("unknown".into());
}
let build = String::from_utf8(output.stdout).ok()?;
Some(build.trim().to_owned())
}
pub fn build_number() -> &'static str {
static BUILD: LazyLock<String> = LazyLock::new(|| {
std::env::current_exe()
.ok()
.and_then(|executable| build_from_executable(&executable))
.unwrap_or_else(|| {
// Tauri omits macOS bundle settings from its generated runtime config.
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
config["bundle"]["macOS"]["bundleVersion"]
.as_str()
.unwrap_or("unknown")
.to_owned()
})
});
&BUILD
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn installed_build_comes_from_the_app_plist_instead_of_the_app_version() {
let root = std::env::temp_dir().join(format!("kanbangit-build-{}", std::process::id()));
let contents = root.join("KanbanGit.app/Contents");
std::fs::create_dir_all(contents.join("MacOS")).unwrap();
let executable = contents.join("MacOS/kanbangit-native");
std::fs::write(contents.join("Info.plist"), r#"<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleShortVersionString</key><string>0.1.3</string><key>CFBundleVersion</key><string>42</string></dict></plist>"#).unwrap();
assert_eq!(build_from_executable(&executable).as_deref(), Some("42"));
std::fs::remove_file(contents.join("Info.plist")).unwrap();
assert_eq!(
build_from_executable(&executable).as_deref(),
Some("unknown")
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn unbundled_execution_uses_the_configured_build() {
assert!(build_from_executable(Path::new("/tmp/target/debug/kanbangit-native")).is_none());
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
assert_eq!(
build_number(),
config["bundle"]["macOS"]["bundleVersion"].as_str().unwrap()
);
}
}
+16 -13
View File
@@ -123,19 +123,28 @@ fn trusted_navigation(url: &Url) -> bool {
&& url.username().is_empty()
&& url.password().is_none()
}
pub fn refresh_menu(app: &AppHandle, available: bool, busy: bool) {
if let Some(menu) = app
.menu()
.and_then(|menu| menu.get("app-menu"))
.and_then(|item| {
item.as_submenu()
.and_then(|menu| menu.get("install-update"))
})
{
if let Some(item) = menu.as_menuitem() {
let _ = item.set_enabled(available && !busy);
}
}
}
pub fn refresh(app: &AppHandle) {
let (view, available, busy) = {
let state = app.state::<Mutex<State>>();
let state = state.lock().unwrap();
(state.view.clone(), state.update.is_some(), state.busy())
};
if let Some(menu) = app.menu().and_then(|m| m.get("install-update")) {
if let Some(item) = menu.as_menuitem() {
let _ = item.set_enabled(available && !busy);
}
}
refresh_menu(app, available, busy);
if let Some(window) = app.get_webview_window("updates") {
let _ = window.set_badge_label(available.then(|| crate::locale::en::UPDATE_BADGE.into()));
if let Ok(json) = serde_json::to_string(&view) {
let _ = window.eval(format!("window.updateStatus?.({json})"));
}
@@ -442,13 +451,7 @@ pub fn setup(app: &AppHandle) -> tauri::Result<()> {
view: View {
phase: if error.is_some() { "error" } else { "idle" },
current_version: app.package_info().version.to_string(),
build: app
.config()
.bundle
.macos
.bundle_version
.clone()
.unwrap_or_else(|| app.package_info().version.to_string()),
build: crate::metadata::build_number().into(),
version: None,
notes: None,
downloaded: 0,
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit",
"version": "0.1.2",
"version": "0.2.1",
"identifier": "de.kanbangit.desktop",
"build": {
"frontendDist": "../ui"
@@ -25,7 +25,7 @@
],
"macOS": {
"minimumSystemVersion": "14.0",
"bundleVersion": "2",
"bundleVersion": "7",
"dmg": {
"windowSize": {
"width": 660,
+39 -1
View File
@@ -1,3 +1,27 @@
#[cfg(target_os = "macos")]
#[tauri::command]
fn updater_smoke_menu(app: tauri::AppHandle) -> Result<(), String> {
let submenu = app.menu().unwrap().get("app-menu").unwrap();
let item = submenu.as_submenu().unwrap().get("install-update").unwrap();
let item = item.as_menuitem().unwrap();
for (available, busy, expected) in [
(false, false, false), // No update.
(true, false, true), // Available update, also after a retryable failure.
(true, true, false), // Download or installation in progress.
(true, false, true), // Retry after a failed download.
(false, true, false), // A new check clears the previous update.
(false, false, false), // Up to date.
] {
kanbangit_native::refresh_update_menu_for_smoke_test(&app, available, busy);
if item.is_enabled().map_err(|error| error.to_string())? != expected {
return Err(format!(
"Native install menu has wrong state: available={available}, busy={busy}"
));
}
}
Ok(())
}
#[cfg(target_os = "macos")]
#[tauri::command]
fn updater_smoke_report(app: tauri::AppHandle, passed: bool, reason: String) {
@@ -16,8 +40,16 @@ fn main() {
};
use tauri::webview::PageLoadEvent;
let passed = Arc::new(AtomicBool::new(false));
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
let expected_installed = format!(
"Installed version {} (build {})",
config["version"].as_str().unwrap(),
config["bundle"]["macOS"]["bundleVersion"].as_str().unwrap()
);
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("updater-smoke")
.js_init_script(format!("window.expectedInstalled = {};", serde_json::to_string(&expected_installed).unwrap()))
.on_page_load(|window, payload| {
if window.label() != "updates" || payload.event() != PageLoadEvent::Finished { return; }
window.eval(r#"
@@ -26,6 +58,9 @@ fn main() {
for (let i = 0; i < 100 && !window.updateStatus; i++) await new Promise(r => setTimeout(r, 50));
if (!window.updateStatus) throw new Error('Update UI did not load');
const $ = id => document.getElementById(id);
for (let i = 0; i < 100 && !$('installed').textContent; i++) await new Promise(r => setTimeout(r, 50));
if ($('installed').textContent !== window.expectedInstalled) throw new Error('Actual installed version/build failed: ' + $('installed').textContent);
await window.__TAURI_INTERNALS__.invoke('updater_smoke_menu');
const state = { phase: 'current', currentVersion: '0.1.2', build: '2', version: null, notes: null, downloaded: 0, total: null, error: null, preferences: { automatic: true, intervalHours: 6 } };
window.updateStatus(state);
if (!$('installed').textContent.includes('0.1.2 (build 2)') || !$('install').hidden || $('check').disabled) throw new Error('Current version state failed');
@@ -54,7 +89,10 @@ fn main() {
context.config_mut().identifier = format!("de.kanbangit.smoke.updates.{}", std::process::id());
let app = kanbangit_native::builder()
.manage(passed.clone())
.invoke_handler(tauri::generate_handler![updater_smoke_report])
.invoke_handler(tauri::generate_handler![
updater_smoke_report,
updater_smoke_menu
])
.plugin(plugin)
.build(context)
.expect("App must start");
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test';
import { conditionalEtag } from '../publish-macos.mjs';
test(
'Hetzner conditional writes accept a bare ETag and reject a stale ETag',
{
skip: process.env.KANBANGIT_S3_INTEGRATION !== '1',
},
() => {
const bucket = process.env.AWS_BUCKET;
assert(bucket, 'Set AWS_BUCKET to a test bucket');
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-s3-etag-'));
const key = `kanbangit-integration-tests/${randomUUID()}.txt`;
const file = join(directory, 'object.txt');
const downloaded = join(directory, 'downloaded.txt');
const run = (operation, args) => {
const result = spawnSync(
'aws',
[
'--debug',
'--endpoint-url',
process.env.AWS_ENDPOINT,
'--region',
process.env.AWS_DEFAULT_REGION,
'--no-cli-pager',
's3api',
operation,
'--bucket',
bucket,
'--key',
key,
...args,
],
{ encoding: 'utf8', timeout: 30000 },
);
// Never expose debug headers: they contain the signed Authorization header.
return {
status: result.status,
stdout: result.stdout,
codes: [
...(result.stderr || '').matchAll(/<Code>([^<]*)<\/Code>/g),
].map((match) => match[1]),
};
};
let created = false;
try {
writeFileSync(file, 'first');
const initial = run('put-object', [
'--body',
file,
'--if-none-match',
'*',
]);
assert.equal(initial.status, 0, `Create failed: ${initial.codes}`);
created = true;
const read = run('get-object', ['--output', 'json', downloaded]);
assert.equal(read.status, 0, `Read failed: ${read.codes}`);
const etag = JSON.parse(read.stdout).ETag;
const quoted = run('put-object', [
'--body',
file,
'--if-match',
etag,
]);
console.log(
`Quoted ETag result: exit ${quoted.status}, S3 ${quoted.codes.join(', ')}`,
);
const bare = conditionalEtag(etag);
writeFileSync(file, 'second');
const updated = run('put-object', [
'--body',
file,
'--if-match',
bare,
]);
assert.equal(
updated.status,
0,
`Bare ETag replacement failed: ${updated.codes}`,
);
writeFileSync(file, 'stale writer');
const stale = run('put-object', [
'--body',
file,
'--if-match',
bare,
]);
assert.notEqual(stale.status, 0);
assert(
stale.codes.includes('PreconditionFailed'),
`Unexpected stale error: ${stale.codes}`,
);
assert.equal(run('get-object', [downloaded]).status, 0);
assert.equal(readFileSync(downloaded, 'utf8'), 'second');
} finally {
try {
if (created) {
const cleanup = run('delete-object', []);
assert.equal(
cleanup.status,
0,
`Cleanup failed: ${cleanup.codes}`,
);
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
},
);
+35 -1
View File
@@ -75,7 +75,7 @@ function fixture(t) {
);
return { ...result, output: readFileSync(output, 'utf8') };
};
return { run, git, checkout, commit };
return { run, git, checkout, remote, commit };
}
test('release selection succeeds using Git alone without an administrative API token', (t) => {
@@ -111,6 +111,39 @@ test('manual release on main accepts an existing tag without moving it', (t) =>
});
assert.equal(result.status, 0, result.stderr);
assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit);
assert.doesNotMatch(workflow, /inputs\.mode|RELEASE_MODE|\n {2}resume:/);
assert.match(workflow, /--ci --target .* --bundles app,dmg/);
assert.match(workflow, /node native\/macos-ci.mjs package/);
assert.match(workflow, /node native\/publish-macos.mjs upload/);
});
test('a moved macOS tag selects the new main commit for a full rebuild', (t) => {
const f = fixture(t);
f.git(
f.remote,
'-c',
'commit.gpgsign=false',
'commit',
'--allow-empty',
'-m',
'Fix failed release',
);
const rebuilt = f.git(f.remote, 'rev-parse', 'HEAD');
f.git(f.remote, 'tag', '--force', 'macos-v1.2.3');
f.git(
f.checkout,
'fetch',
'--force',
'origin',
'refs/tags/macos-v1.2.3:refs/tags/macos-v1.2.3',
);
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.equal(result.output, `commit=${rebuilt}\ntag=macos-v1.2.3\n`);
assert.notEqual(rebuilt, f.commit);
assert.match(workflow, /tags:\n\s+- "macos-v\*"/);
assert.match(workflow, /^ {2}group: macos-release$/m);
assert.match(workflow, /^ {2}cancel-in-progress: false$/m);
});
test('release DMG packaging disables Finder automation in the Tauri action environment', () => {
@@ -127,6 +160,7 @@ test('release DMG packaging disables Finder automation in the Tauri action envir
});
for (const [name, overrides] of [
['wrong tag case', { GITHUB_REF: 'refs/tags/macOS-V1.2.3' }],
['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }],
['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }],
[
+487 -28
View File
@@ -1,14 +1,64 @@
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test';
import { publishMacos, storageConfiguration } from '../publish-macos.mjs';
import { fileURLToPath } from 'node:url';
import {
conditionalEtag,
publishMacos,
storageConfiguration,
} from '../publish-macos.mjs';
import { signedFixture } from './updater-fixture.mjs';
function fixture(t) {
test('publisher rejects the removed resume command before accessing storage', () => {
const result = spawnSync(
process.execPath,
[
fileURLToPath(new URL('../publish-macos.mjs', import.meta.url)),
'resume',
],
{ encoding: 'utf8' },
);
assert.equal(result.status, 1);
assert.equal(result.stdout, '');
assert.match(
result.stderr,
/Use config or upload; upload requires RELEASE_DIRECTORY/,
);
});
test('conditional writes use bare S3 ETags without weakening the condition', () => {
const etag = 'b88312b96169f4b2098a4cc43b0abb6a';
for (const value of [etag, `"${etag}"`, `${etag}-2`, `"${etag}-2"`]) {
assert.equal(conditionalEtag(value), value.replaceAll('"', ''));
}
for (const invalid of [
undefined,
null,
'',
'*',
'"*"',
`W/"${etag}"`,
`"${etag}`,
`${etag}"`,
`${etag},${etag}`,
`${etag}\r\n`,
{},
42,
]) {
assert.throws(() => conditionalEtag(invalid), /valid S3 ETag/);
}
});
function fixture(
t,
conflictMessage = 'aws failed: (PreconditionFailed) object exists',
) {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const metadata = {
@@ -49,7 +99,7 @@ function fixture(t) {
const operation = args[args.indexOf('s3api') + 1];
const value = (name) => args[args.indexOf(name) + 1];
const key = value('--key');
events.push({ operation, key });
events.push({ operation, key, args });
if (operation === 'get-bucket-versioning') {
assert.equal(value('--output'), 'json');
return '';
@@ -60,18 +110,24 @@ function fixture(t) {
}
if (objects.has(key)) {
if (!args.includes('--if-match')) {
throw new Error(
'aws failed: (PreconditionFailed) object exists',
);
throw new Error(conflictMessage);
}
assert.equal(value('--if-match'), '"previous-etag"');
assert.equal(
value('--if-match'),
'b88312b96169f4b2098a4cc43b0abb6a',
);
}
objects.set(key, readFileSync(value('--body')));
return '{}';
}
if (operation === 'get-object') {
if (!objects.has(key)) {
throw new Error('aws failed: (NoSuchKey) object missing');
}
writeFileSync(args.at(-1), objects.get(key));
return JSON.stringify({ ETag: '"previous-etag"' });
return JSON.stringify({
ETag: '"b88312b96169f4b2098a4cc43b0abb6a"',
});
}
throw new Error(`Unexpected S3 operation: ${operation}`);
};
@@ -103,7 +159,7 @@ function fixture(t) {
};
}
test('uploads immutable files and writes completion record after public verification', async (t) => {
test('uploads revalidated files and writes completion record after public verification', async (t) => {
const f = fixture(t);
const url = await publishMacos(f);
assert.equal(
@@ -112,14 +168,42 @@ test('uploads immutable files and writes completion record after public verifica
);
assert.equal(
f.events.filter((event) => event.operation === 'put-object').length,
8,
9,
);
assert.equal(f.objects.size, 8);
assert.equal(f.objects.size, 9);
for (const event of f.events.filter(
(event) => event.operation === 'put-object',
)) {
assert.equal(
event.args[event.args.indexOf('--cache-control') + 1],
'no-cache, max-age=0, must-revalidate',
);
}
assert(f.events.at(-1).key.endsWith('/latest.json'));
assert.equal(
f.events.at(-1).key,
'releases/macos/aarch64-apple-darwin/latest.json',
);
const downloadKey = 'releases/macos/aarch64-apple-darwin/KanbanGit.dmg';
assert.deepEqual(
f.objects.get(downloadKey),
readFileSync(join(f.directory, f.metadata.filename)),
);
const downloadPut = f.events.findIndex(
(event) =>
event.operation === 'put-object' && event.key === downloadKey,
);
const downloadVerified = f.events.findIndex(
(event) =>
event.operation === 'public-get' && event.key === downloadKey,
);
const feedPut = f.events.findIndex(
(event) =>
event.operation === 'put-object' &&
event.key.endsWith('/latest.json'),
);
assert(downloadPut > 0 && downloadVerified > downloadPut);
assert(feedPut > downloadVerified);
assert.deepEqual(
JSON.parse(
f.objects.get(
@@ -138,6 +222,91 @@ test('uploads immutable files and writes completion record after public verifica
);
});
test('replaces the stable download only for a newer verified release', async (t) => {
const f = fixture(t);
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(downloadKey, Buffer.from('previous download'));
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
await publishMacos(f);
assert.deepEqual(
f.objects.get(downloadKey),
readFileSync(join(f.directory, f.metadata.filename)),
);
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
const replacement = f.events.find(
(event) =>
event.operation === 'put-object' &&
event.key === downloadKey &&
event.args.includes('--if-match'),
);
assert(replacement);
});
test('a newer published version protects the stable download even when its alias is missing', async (t) => {
const f = fixture(t);
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '2.0.0' })));
await assert.rejects(publishMacos(f), /downgrade/);
assert.equal(f.objects.has(downloadKey), false);
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '2.0.0');
});
test('a published version cannot replace a different stable download', async (t) => {
const f = fixture(t);
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
const original = Buffer.from('published download');
f.objects.set(downloadKey, original);
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.2.3' })));
await assert.rejects(publishMacos(f), /bump the app version/);
assert.deepEqual(f.objects.get(downloadKey), original);
});
test('a failed public stable download blocks feed promotion', async (t) => {
const f = fixture(t);
const fetchPublic = f.fetchPublic;
f.fetchPublic = (url, options) =>
url.endsWith('/KanbanGit.dmg')
? Promise.resolve({ ok: false, status: 403 })
: fetchPublic(url, options);
await assert.rejects(publishMacos(f), /KanbanGit\.dmg: HTTP 403/);
assert.equal(
f.objects.has(`releases/macos/${f.metadata.target}/latest.json`),
false,
);
});
test('concurrent stable download replacement preserves the winner', async (t) => {
const f = fixture(t);
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(downloadKey, Buffer.from('previous download'));
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
const run = f.run;
f.run = (command, args) => {
if (args.includes('--if-match') && args.includes(downloadKey)) {
f.objects.set(downloadKey, Buffer.from('concurrent winner'));
throw new Error('(PreconditionFailed) concurrent replacement');
}
return run(command, args);
};
await assert.rejects(publishMacos(f), /concurrent replacement/);
assert.equal(f.objects.get(downloadKey).toString(), 'concurrent winner');
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.0.0');
});
for (const response of ['', ' \n\t', '{}']) {
test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => {
const f = fixture(t);
@@ -147,7 +316,7 @@ for (const response of ['', ' \n\t', '{}']) {
return args.includes('get-bucket-versioning') ? response : result;
};
await publishMacos(f);
assert.equal(f.objects.size, 8);
assert.equal(f.objects.size, 9);
assert.equal(f.events.at(-1).operation, 'public-get');
assert(f.events.at(-1).key.endsWith('/latest.json'));
});
@@ -173,18 +342,24 @@ test('retry accepts byte-identical objects without replacing them', async (t) =>
assert.deepEqual([...f.objects.entries()], originals);
assert.equal(
f.events.filter((event) => event.operation === 'get-object').length,
8,
11,
);
});
test('different existing release is never overwritten', async (t) => {
const f = fixture(t);
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
f.objects.set(key, Buffer.from('previous version content'));
await assert.rejects(publishMacos(f), /bump the app version/);
assert.equal(f.objects.get(key).toString(), 'previous version content');
assert.equal(f.objects.size, 1);
});
for (const published of ['1.2.3', '1.2.4']) {
test(`published release stays protected when the feed is at ${published}`, async (t) => {
const f = fixture(t);
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
f.objects.set(key, Buffer.from('previous version content'));
f.objects.set(
`releases/macos/${f.metadata.target}/latest.json`,
Buffer.from(JSON.stringify({ version: published })),
);
await assert.rejects(publishMacos(f), /bump the app version/);
assert.equal(f.objects.get(key).toString(), 'previous version content');
assert.equal(f.objects.size, 2);
});
}
test('checksum mismatch aborts before contacting S3', async (t) => {
const f = fixture(t);
@@ -239,7 +414,7 @@ for (const failure of ['private', 'wrong-content', 'network']) {
});
}
test('a partial upload can resume with the same files', async (t) => {
test('a partial upload can be retried with the same files', async (t) => {
const f = fixture(t);
const run = f.run;
f.run = (command, args) => {
@@ -255,9 +430,206 @@ test('a partial upload can resume with the same files', async (t) => {
assert.equal(f.objects.size, 3);
f.run = run;
await publishMacos(f);
assert.equal(f.objects.size, 8);
assert.equal(f.objects.size, 9);
});
function rebuild(f) {
const updater = Buffer.from('rebuilt signed update');
const signing = signedFixture(updater);
f.publicKey = signing.publicKey;
f.metadata.notes = 'Fixed build of the same version';
for (const [name, contents] of [
[f.metadata.filename, Buffer.from('rebuilt disk image')],
[f.metadata.updaterFilename, updater],
]) {
writeFileSync(join(f.directory, name), contents);
writeFileSync(
join(f.directory, `${name}.sha256`),
`${createHash('sha256').update(contents).digest('hex')} ${name}\n`,
);
}
writeFileSync(
join(f.directory, `${f.metadata.updaterFilename}.sig`),
signing.signature,
);
}
for (const previous of [null, '1.0.0']) {
for (const interruptedFile of ['.sha256', '/latest.json']) {
test(`rebuild replaces unfinished ${interruptedFile} release with previous feed ${previous}`, async (t) => {
const f = fixture(t);
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
const previousBytes =
previous === null
? undefined
: Buffer.from(JSON.stringify({ version: previous }));
if (previousBytes) {
f.objects.set(latestKey, previousBytes);
}
const run = f.run;
f.run = (command, args) => {
if (
args.includes('put-object') &&
args[args.indexOf('--key') + 1].endsWith(interruptedFile)
) {
throw new Error('upload interrupted');
}
return run(command, args);
};
await assert.rejects(publishMacos(f), /upload interrupted/);
assert.deepEqual(f.objects.get(latestKey), previousBytes);
rebuild(f);
f.events.length = 0;
let promoted = false;
f.run = (command, args) => {
if (!promoted) {
assert.deepEqual(f.objects.get(latestKey), previousBytes);
}
const result = run(command, args);
if (args.includes('put-object') && args.includes(latestKey)) {
promoted = true;
}
return result;
};
await publishMacos(f);
assert.equal(f.objects.size, 9);
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
assert.equal(
JSON.parse(f.objects.get(latestKey)).notes,
f.metadata.notes,
);
for (const name of [
f.metadata.filename,
`${f.metadata.filename}.sha256`,
f.metadata.updaterFilename,
`${f.metadata.updaterFilename}.sig`,
`${f.metadata.updaterFilename}.sha256`,
]) {
assert.deepEqual(
f.objects.get(
`releases/macos/${f.metadata.target}/1.2.3/${name}`,
),
readFileSync(join(f.directory, name)),
);
}
const published = [...f.objects.entries()];
rebuild(f);
await assert.rejects(publishMacos(f), /already been released/);
assert.deepEqual([...f.objects.entries()], published);
});
}
}
test('failure after promoting the feed still protects that version from rebuilding', async (t) => {
const f = fixture(t);
const fetchPublic = f.fetchPublic;
f.fetchPublic = async (url, options) =>
url.endsWith('/latest.json')
? { ok: false, status: 403 }
: fetchPublic(url, options);
await assert.rejects(publishMacos(f), /HTTP 403/);
const published = [...f.objects.entries()];
rebuild(f);
await assert.rejects(publishMacos(f), /already been released/);
assert.deepEqual([...f.objects.entries()], published);
});
test('a concurrent change to a partial artifact is not overwritten', async (t) => {
const f = fixture(t);
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
f.objects.set(key, Buffer.from('unfinished old build'));
const run = f.run;
f.run = (command, args) => {
if (args.includes('--if-match')) {
assert(args.includes(key));
f.objects.set(key, Buffer.from('concurrent winner'));
throw new Error('(PreconditionFailed) concurrent replacement');
}
return run(command, args);
};
await assert.rejects(publishMacos(f), /concurrent replacement/);
assert.equal(f.objects.get(key).toString(), 'concurrent winner');
assert.equal(f.objects.size, 1);
});
for (const failure of [
'AccessDenied',
'network failure',
'invalid JSON',
'invalid version',
]) {
test(`cannot replace a partial artifact when the feed check has ${failure}`, async (t) => {
const f = fixture(t);
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from('unfinished old build'));
f.objects.set(
latestKey,
Buffer.from(
failure === 'invalid version' ? '{"version":"invalid"}' : '{',
),
);
const originals = [...f.objects.entries()];
const run = f.run;
f.run = (command, args) => {
if (
args.includes('get-object') &&
args.includes(latestKey) &&
!failure.startsWith('invalid')
) {
throw new Error(failure);
}
return run(command, args);
};
await assert.rejects(publishMacos(f));
assert.deepEqual([...f.objects.entries()], originals);
});
}
for (const listing of [
{ KeyCount: 0, IsTruncated: false },
{ KeyCount: 1, IsTruncated: false, Contents: [{ Key: 'latest.json' }] },
{},
{ KeyCount: 0, IsTruncated: true },
{ KeyCount: 0, IsTruncated: false, Contents: '' },
'AccessDenied',
]) {
test(`ambiguous feed read error needs a proven empty listing: ${JSON.stringify(listing)}`, async (t) => {
const f = fixture(t);
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from('unfinished old build'));
const run = f.run;
f.run = (command, args) => {
if (args.includes('get-object') && args.includes(latestKey)) {
throw new Error(
"argument of type 'NoneType' is not a container or iterable",
);
}
if (args.includes('list-objects-v2')) {
assert.equal(args[args.indexOf('--prefix') + 1], latestKey);
if (listing === 'AccessDenied') {
throw new Error('AccessDenied');
}
return JSON.stringify(listing);
}
return run(command, args);
};
if (
listing.KeyCount === 0 &&
listing.IsTruncated === false &&
!('Contents' in listing)
) {
await publishMacos(f);
assert.equal(f.objects.size, 9);
} else {
await assert.rejects(publishMacos(f), /NoneType|AccessDenied/);
assert.equal(f.objects.get(key).toString(), 'unfinished old build');
assert.equal(f.objects.size, 1);
}
});
}
for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
test(`temporary public download failure ${status} recovers before completion`, async (t) => {
const f = fixture(t);
@@ -282,7 +654,7 @@ for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
await publishMacos(f);
assert.deepEqual(waits, [2000, 2000]);
assert.equal(cancelled, status === 'network' ? 0 : 2);
assert.equal(f.objects.size, 8);
assert.equal(f.objects.size, 9);
assert(f.events.at(-1).key.endsWith('/latest.json'));
});
}
@@ -326,7 +698,7 @@ test('checksum access retries finish before publishing the completion record', a
};
await publishMacos(f);
assert.equal(failures, 3);
assert.equal(f.objects.size, 8);
assert.equal(f.objects.size, 9);
});
for (const overrides of [
@@ -378,13 +750,13 @@ test('rejects a corrupt updater archive before publishing any object', async (t)
assert.equal(f.objects.size, 0);
});
test('atomically promotes a newer feed only after all immutable objects are public', async (t) => {
test('atomically promotes a newer feed only after all release files are public', async (t) => {
const f = fixture(t);
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
await publishMacos(f);
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
const immutableVerified = f.events.findIndex(
const releaseVerified = f.events.findIndex(
(event) =>
event.operation === 'public-get' &&
event.key.endsWith('/update.json'),
@@ -392,7 +764,7 @@ test('atomically promotes a newer feed only after all immutable objects are publ
const latestPut = f.events.findIndex(
(event) => event.operation === 'put-object' && event.key === latestKey,
);
assert(latestPut > immutableVerified);
assert(latestPut > releaseVerified);
});
for (const previous of [
@@ -427,3 +799,90 @@ test('concurrent feed replacement fails without overwriting the winner', async (
await assert.rejects(publishMacos(f), /concurrent publication/);
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
});
for (const message of [
"aws failed (exit 255). aws: [ERROR]: argument of type 'NoneType' is not a container or iterable",
"aws failed (exit 255). argument of type 'NoneType' is not iterable",
]) {
test(`empty S3 error message permits verified promotion and identical retry: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
await publishMacos(f);
assert.equal(JSON.parse(f.objects.get(key)).version, '1.2.3');
const published = [...f.objects.entries()];
await publishMacos(f);
assert.deepEqual([...f.objects.entries()], published);
});
test(`empty S3 error cannot replace a published release: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
f.objects.set(key, Buffer.from('different existing release'));
f.objects.set(
`releases/macos/${f.metadata.target}/latest.json`,
Buffer.from(JSON.stringify({ version: '1.2.3' })),
);
await assert.rejects(publishMacos(f), /bump the app version/);
assert.equal(f.objects.size, 2);
assert.equal(
f.objects.get(key).toString(),
'different existing release',
);
});
for (const previous of [
{ version: '2.0.0' },
{ version: '1.2.3', notes: 'different' },
]) {
test(`empty S3 error cannot bypass feed conflict checks ${previous.version}: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/latest.json`;
const original = Buffer.from(JSON.stringify(previous));
f.objects.set(key, original);
await assert.rejects(publishMacos(f), /downgrade|differs/);
assert.deepEqual(f.objects.get(key), original);
});
}
test(`empty S3 error aborts when existing contents cannot be read: ${message}`, async (t) => {
const f = fixture(t, message);
const run = f.run;
f.run = (command, args) => {
if (args.includes('put-object')) {
throw new Error(message);
}
if (args.includes('get-object')) {
throw new Error('AccessDenied');
}
return run(command, args);
};
await assert.rejects(publishMacos(f), /S3 get-object .*AccessDenied/);
assert.equal(f.objects.size, 0);
});
test(`empty S3 error during conditional replacement preserves a concurrent winner: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
const run = f.run;
let attempts = 0;
f.run = (command, args) => {
if (args.includes('--if-match')) {
attempts++;
f.objects.set(
key,
Buffer.from(JSON.stringify({ version: '2.0.0' })),
);
throw new Error(message);
}
return run(command, args);
};
await assert.rejects(
publishMacos(f),
/S3 put-object .*latest.json: .*NoneType/,
);
assert.equal(attempts, 1);
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
});
}
+18 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
import { Download, Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
import { computed } from 'vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue';
import UserMenuContent from '@/components/UserMenuContent.vue';
@@ -23,6 +23,7 @@ import {
} from '@/composables/useAppearance';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { useMacDownload } from '@/composables/useMacDownload';
import { create as createProject } from '@/routes/projects';
import { index as board } from '@/routes/projects/board';
import { create as createTicket } from '@/routes/projects/tickets';
@@ -35,6 +36,7 @@ const page = usePage();
const { project } = useAppNavigation();
const { isCurrentUrl } = useCurrentUrl();
const { t } = useLocalization();
const { macDownloadUrl, showMacDownload } = useMacDownload();
const { appearance, updateAppearance } = useAppearance();
const themeIcon = computed(
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
@@ -69,6 +71,21 @@ const createAction = computed(() => {
<span v-else class="truncate font-semibold">KanbanGit</span>
</div>
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
<Button
v-if="showMacDownload"
as-child
variant="outline"
:aria-label="t('navigation.downloadAppAppleSilicon')"
:title="t('navigation.downloadAppAppleSilicon')"
class="px-3"
>
<a :href="macDownloadUrl"
><Download aria-hidden="true" /><span
class="hidden sm:inline"
>{{ t('navigation.downloadApp') }}</span
></a
>
</Button>
<Button
v-if="createAction"
as-child
@@ -0,0 +1,30 @@
import { onMounted, ref } from 'vue';
import updateSource from '../../../native/update-source.json';
export const macDownloadUrl = `${updateSource.baseUrl}/aarch64-apple-darwin/KanbanGit.dmg`;
export function isMacDownloadAvailable(
userAgent: string,
maxTouchPoints: number,
isTauri: unknown,
): boolean {
return (
userAgent.includes('Macintosh') &&
maxTouchPoints < 2 &&
isTauri !== true
);
}
export function useMacDownload() {
const showMacDownload = ref(false);
onMounted(() => {
showMacDownload.value = isMacDownloadAvailable(
navigator.userAgent,
navigator.maxTouchPoints,
Reflect.get(window, 'isTauri'),
);
});
return { macDownloadUrl, showMacDownload };
}
+20 -2
View File
@@ -1,20 +1,23 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ShieldCheck } from 'lucide-vue-next';
import { Download, ShieldCheck } from 'lucide-vue-next';
import type { Component } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import AppearanceTabs from '@/components/AppearanceTabs.vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import { useMacDownload } from '@/composables/useMacDownload';
import { home } from '@/routes/index';
defineProps<{ title: string; description: string; icon?: Component }>();
const { t } = useLocalization();
const { macDownloadUrl, showMacDownload } = useMacDownload();
</script>
<template>
<div class="bg-background text-foreground flex min-h-dvh flex-col">
<header
class="bg-card px-page flex justify-center border-b py-4 sm:justify-start"
class="bg-card px-page flex items-center justify-between gap-3 border-b py-4"
>
<Link
:href="home()"
@@ -22,6 +25,21 @@ const { t } = useLocalization();
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
><AppLogo
/></Link>
<Button
v-if="showMacDownload"
as-child
variant="outline"
size="sm"
:aria-label="t('navigation.downloadAppAppleSilicon')"
:title="t('navigation.downloadAppAppleSilicon')"
>
<a :href="macDownloadUrl"
><Download aria-hidden="true" /><span
class="hidden sm:inline"
>{{ t('navigation.downloadApp') }}</span
></a
>
</Button>
</header>
<main
class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12"
+4
View File
@@ -718,6 +718,7 @@ export const en = {
'welcome.action.dashboard': 'Open dashboard',
'welcome.action.dashboardShort': 'Dashboard',
'welcome.actionHint': 'One workspace for boards, tickets, and Git context.',
'welcome.downloadAppHint': 'For Macs with Apple Silicon.',
'welcome.preview.label': 'KanbanGit board preview',
'welcome.preview.project': 'Product delivery board',
'welcome.preview.connected': 'Git connected',
@@ -899,6 +900,9 @@ export const en = {
'settings.eyebrow': 'Account settings',
'navigation.createTicket': 'New ticket',
'navigation.createProject': 'New project',
'navigation.downloadApp': 'Download App',
'navigation.downloadAppAppleSilicon':
'Download App for Macs with Apple Silicon',
'navigation.open': 'Open navigation',
'navigation.close': 'Close navigation',
'navigation.expand': 'Expand sidebar',
+41 -11
View File
@@ -1,13 +1,21 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowRight, Columns3, GitBranch, ListChecks } from 'lucide-vue-next';
import {
ArrowRight,
Columns3,
Download,
GitBranch,
ListChecks,
} from 'lucide-vue-next';
import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import { useMacDownload } from '@/composables/useMacDownload';
import { dashboard, home, login } from '@/routes/index';
const { t } = useLocalization();
const { macDownloadUrl, showMacDownload } = useMacDownload();
const features = computed(() => [
{
@@ -112,16 +120,38 @@ const previewColumns = computed(() => [
{{ t('welcome.description') }}
</p>
</div>
<Button as-child size="lg">
<Link v-if="$page.props.auth.user" :href="dashboard()">
{{ t('welcome.action.dashboard') }}
<ArrowRight aria-hidden="true" />
</Link>
<Link v-else :href="login()">
{{ t('welcome.action.signIn') }}
<ArrowRight aria-hidden="true" />
</Link>
</Button>
<div
class="flex flex-wrap items-start justify-center gap-3"
>
<Button as-child size="lg">
<Link
v-if="$page.props.auth.user"
:href="dashboard()"
>
{{ t('welcome.action.dashboard') }}
<ArrowRight aria-hidden="true" />
</Link>
<Link v-else :href="login()">
{{ t('welcome.action.signIn') }}
<ArrowRight aria-hidden="true" />
</Link>
</Button>
<div
v-if="showMacDownload"
class="grid justify-items-center gap-1"
>
<Button as-child size="lg" variant="outline">
<a :href="macDownloadUrl"
><Download aria-hidden="true" />{{
t('navigation.downloadApp')
}}</a
>
</Button>
<p class="text-caption text-muted-foreground">
{{ t('welcome.downloadAppHint') }}
</p>
</div>
</div>
<p class="text-caption text-muted-foreground">
{{ t('welcome.actionHint') }}
</p>
+22
View File
@@ -90,10 +90,32 @@ if (serve) {
const { en } = await server.ssrLoadModule(
'/resources/js/locales/en.ts',
);
const { isMacDownloadAvailable, macDownloadUrl } =
await server.ssrLoadModule(
'/resources/js/composables/useMacDownload.ts',
);
const { default: Welcome } = await server.ssrLoadModule(
'/resources/js/pages/Welcome.vue',
);
assert.equal(
macDownloadUrl,
'https://petsch-studios.nbg1.your-objectstorage.com/releases/macos/aarch64-apple-darwin/KanbanGit.dmg',
);
const macUserAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
assert.equal(isMacDownloadAvailable(macUserAgent, 0, undefined), true);
assert.equal(isMacDownloadAvailable(macUserAgent, 0, true), false);
assert.equal(isMacDownloadAvailable(macUserAgent, 5, undefined), false);
assert.equal(
isMacDownloadAvailable(
'Mozilla/5.0 (Windows NT 10.0)',
0,
undefined,
),
false,
);
const render = async (url, localization = createLocalization()) => {
const response = await createInertiaApp({
page: makePage(url),