Skip to main content

fission_command_ui/publish/
mod.rs

1use anyhow::{Context, Result};
2use fission::op::{Color, Fill};
3use fission::prelude::*;
4use fission_command_core::{read_project_config, DistributionProvider, Target};
5use fission_command_package::{
6    package_silent, CheckSeverity, CheckStatus, PackageFormat, PackageOptions, PublishShellOptions,
7    ReadinessCheck,
8};
9use fission_command_release::{publish_workflow, PublishWorkflowOptions, ReleasePlanSnapshot};
10use serde::{Deserialize, Serialize};
11use std::env;
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::sync::{Arc, Mutex};
16use std::thread;
17
18mod fission_toml;
19mod fs_ops;
20mod snapshot;
21mod style;
22#[cfg(test)]
23mod tests;
24mod widgets;
25
26use fission_toml::*;
27use fs_ops::*;
28use snapshot::*;
29use style::theme_for_mode;
30pub use widgets::PublishApp;
31
32#[derive(Clone, Debug)]
33pub struct PublishUiOptions {
34    pub project_dir: PathBuf,
35    pub provider: DistributionProvider,
36    pub target: Option<Target>,
37    pub format: Option<PackageFormat>,
38    pub artifact: Option<PathBuf>,
39    pub site: String,
40    pub deploy: Option<String>,
41    pub track: Option<String>,
42    pub locales: Vec<String>,
43    pub screenshot: Option<PathBuf>,
44    pub exit_after_render: bool,
45    pub width: Option<u16>,
46    pub height: Option<u16>,
47    pub native_file_dialog: bool,
48}
49
50pub fn run_publish_tui(options: PublishUiOptions) -> Result<()> {
51    let run_options = fission::terminal::TerminalRunOptions {
52        width: options.width,
53        height: options.height,
54        screenshot: options.screenshot.clone(),
55        exit_after_render: options.exit_after_render,
56        ..fission::terminal::TerminalRunOptions::default()
57    };
58    let mut options = options;
59    options.native_file_dialog = false;
60    let state = PublishUiState::load(options);
61    fission::terminal::TerminalApp::with_state(PublishApp, state)
62        .with_title("Fission publish")
63        .with_env(|env| env.theme = fission::theme::Theme::dark())
64        .with_sync_env(|state, env| env.theme = theme_for_mode(state.theme_mode))
65        .with_key_handler(publish_key_handler)
66        .with_state_update(|state, _runtime, _env| state.poll_background_tasks())
67        .run_with_options(run_options)
68}
69
70pub fn run_publish_window(options: PublishUiOptions) -> Result<()> {
71    let mut options = options;
72    options.native_file_dialog = true;
73    let state = PublishUiState::load(options);
74    fission::DesktopApp::<PublishUiState, _>::new_with_global_state(PublishApp, state)
75        .with_title("Fission Publish")
76        .with_sync_env(|state, env| env.theme = theme_for_mode(state.theme_mode))
77        .with_key_handler(publish_key_handler)
78        .with_frame_hook(|state| state.poll_background_tasks())
79        .run()
80}
81
82fn publish_key_handler(
83    state: &mut PublishUiState,
84    code: &fission::KeyCode,
85    _modifiers: u8,
86) -> bool {
87    state.handle_key(code)
88}
89
90#[derive(Clone, Debug, PartialEq)]
91pub struct PublishUiState {
92    pub project_dir: PathBuf,
93    pub app_name: String,
94    pub app_id: String,
95    pub board: PublishBoard,
96    pub current_step: usize,
97    pub target: Target,
98    pub format: PackageFormat,
99    pub provider: DistributionProvider,
100    pub site: String,
101    pub deploy: Option<String>,
102    pub track: String,
103    pub locales_input: String,
104    pub workspace: PathBuf,
105    pub artifact_manifest: PathBuf,
106    pub package_checks: Vec<UiCheck>,
107    pub distribution_checks: Vec<UiCheck>,
108    pub release_checks: Vec<UiCheck>,
109    pub release_plan: Option<ReleasePlanSnapshot>,
110    pub status_message: String,
111    pub theme_mode: ThemeMode,
112    pub file_picker: Option<FilePickerState>,
113    pub selected_file: Option<FileSelection>,
114    pub(crate) config_editor: Option<FissionTomlEditorState>,
115    pub play_json_path: String,
116    pub android_jks_path: String,
117    pub android_alias: String,
118    pub android_password: String,
119    pub app_store_key_path: String,
120    pub app_store_key_id: String,
121    pub app_store_issuer_id: String,
122    pub windows_pfx_path: String,
123    pub windows_password: String,
124    pub azure_tenant_id: String,
125    pub azure_client_id: String,
126    pub microsoft_secret: String,
127    pub aws_profile: String,
128    pub aws_region: String,
129    pub aws_endpoint: String,
130    pub aws_access_key_id: String,
131    pub aws_secret_access_key: String,
132    pub publish_confirmation: String,
133    pub task: Option<PublishTaskState>,
134    pub task_revision_seen: u64,
135    pub task_log: Vec<String>,
136    pub(crate) snapshot_task: Option<SnapshotRefreshState>,
137    pub snapshot_task_revision_seen: u64,
138    pub native_file_dialog: bool,
139}
140
141impl GlobalState for PublishUiState {}
142
143impl Default for PublishUiState {
144    fn default() -> Self {
145        Self::load(default_publish_options(PathBuf::from(".")))
146    }
147}
148
149impl PublishUiState {
150    pub fn load(options: PublishUiOptions) -> Self {
151        let board = PublishBoard::from_provider(options.provider);
152        let target = options.target.unwrap_or_else(|| board.target());
153        let format = options.format.unwrap_or_else(|| board.format());
154        let provider = options.provider;
155        let track = options
156            .track
157            .clone()
158            .unwrap_or_else(|| board.default_track().to_string());
159        let locales_input = if options.locales.is_empty() {
160            "".to_string()
161        } else {
162            options.locales.join(", ")
163        };
164        let mut state = Self {
165            project_dir: options.project_dir.clone(),
166            app_name: "workspace".to_string(),
167            app_id: "unknown".to_string(),
168            board,
169            current_step: 1,
170            target,
171            format,
172            provider,
173            site: options.site,
174            deploy: options.deploy,
175            track,
176            locales_input,
177            workspace: PathBuf::new(),
178            artifact_manifest: options.artifact.unwrap_or_default(),
179            package_checks: Vec::new(),
180            distribution_checks: Vec::new(),
181            release_checks: Vec::new(),
182            release_plan: None,
183            status_message: "Loading project".to_string(),
184            theme_mode: ThemeMode::Dark,
185            file_picker: None,
186            selected_file: None,
187            config_editor: None,
188            play_json_path: String::new(),
189            android_jks_path: String::new(),
190            android_alias: String::new(),
191            android_password: String::new(),
192            app_store_key_path: String::new(),
193            app_store_key_id: String::new(),
194            app_store_issuer_id: String::new(),
195            windows_pfx_path: String::new(),
196            windows_password: String::new(),
197            azure_tenant_id: String::new(),
198            azure_client_id: String::new(),
199            microsoft_secret: String::new(),
200            aws_profile: String::new(),
201            aws_region: String::new(),
202            aws_endpoint: String::new(),
203            aws_access_key_id: String::new(),
204            aws_secret_access_key: String::new(),
205            publish_confirmation: String::new(),
206            task: None,
207            task_revision_seen: 0,
208            task_log: Vec::new(),
209            snapshot_task: None,
210            snapshot_task_revision_seen: 0,
211            native_file_dialog: options.native_file_dialog,
212        };
213        state.refresh_snapshot();
214        state.load_release_env_values();
215        state
216    }
217
218    fn options(&self) -> PublishShellOptions {
219        PublishShellOptions {
220            project_dir: self.project_dir.clone(),
221            provider: self.provider,
222            target: Some(self.target),
223            format: Some(self.format),
224            artifact: if self.artifact_manifest.as_os_str().is_empty() {
225                None
226            } else {
227                Some(self.artifact_manifest.clone())
228            },
229            site: self.site.clone(),
230            deploy: self.deploy.clone(),
231            track: Some(self.track.clone()).filter(|value| !value.trim().is_empty()),
232            locales: self
233                .locales_input
234                .split(',')
235                .map(str::trim)
236                .filter(|value| !value.is_empty())
237                .map(str::to_string)
238                .collect(),
239            overwrite_remote: false,
240            dry_run: false,
241            yes: false,
242            json: false,
243            app: false,
244        }
245    }
246
247    fn refresh_snapshot(&mut self) {
248        match collect_refresh_snapshot(self.options()) {
249            Ok(result) => self.apply_refresh_result(result),
250            Err(err) => self.apply_refresh_error(err.to_string()),
251        }
252    }
253
254    fn apply_refresh_result(&mut self, result: SnapshotRefreshResult) {
255        let snapshot = result.snapshot;
256        self.app_name = snapshot.app_name;
257        self.app_id = snapshot.app_id;
258        self.provider = snapshot.provider;
259        self.target = snapshot.target;
260        self.format = snapshot.format;
261        self.site = snapshot.site;
262        self.track = snapshot
263            .track
264            .unwrap_or_else(|| self.board.default_track().to_string());
265        if !snapshot.locales.is_empty() && self.locales_input.trim().is_empty() {
266            self.locales_input = snapshot.locales.join(", ");
267        }
268        self.workspace = snapshot.workspace;
269        self.artifact_manifest = snapshot.artifact_manifest;
270        self.package_checks = snapshot
271            .package_checks
272            .into_iter()
273            .map(UiCheck::from)
274            .collect();
275        self.distribution_checks = snapshot
276            .distribution_checks
277            .into_iter()
278            .map(UiCheck::from)
279            .collect();
280        self.release_plan = result.release_plan;
281        self.release_checks = result
282            .release_checks
283            .into_iter()
284            .map(UiCheck::from)
285            .collect();
286        self.status_message = "Preflight refreshed".to_string();
287        if self.android_alias.trim().is_empty() {
288            self.android_alias = sanitize_workspace_name(&self.app_name).replace('.', "-");
289        }
290    }
291
292    fn apply_refresh_error(&mut self, err: String) {
293        self.status_message = format!("Preflight failed: {err}");
294        self.package_checks = vec![UiCheck::failed("Project could not be loaded", err)];
295        self.distribution_checks.clear();
296        self.release_checks.clear();
297        self.release_plan = None;
298    }
299
300    fn start_snapshot_refresh(&mut self) {
301        if self
302            .snapshot_task
303            .as_ref()
304            .is_some_and(|task| task.status() == TaskStatus::Running)
305        {
306            self.status_message = "Preflight refresh is already running".to_string();
307            return;
308        }
309        let options = self.options();
310        let task = SnapshotRefreshState::new();
311        let shared = task.shared.clone();
312        thread::spawn(move || {
313            let result = collect_refresh_snapshot(options).map_err(|err| err.to_string());
314            let mut data = shared.lock().expect("snapshot refresh lock poisoned");
315            data.status = if result.is_ok() {
316                TaskStatus::Ok
317            } else {
318                TaskStatus::Failed
319            };
320            data.message = match &result {
321                Ok(_) => "Preflight refreshed".to_string(),
322                Err(err) => format!("Preflight failed: {err}"),
323            };
324            data.result = Some(result);
325            data.revision = data.revision.saturating_add(1);
326        });
327        self.snapshot_task = Some(task);
328        self.snapshot_task_revision_seen = 0;
329        self.status_message = "Refreshing preflight...".to_string();
330    }
331
332    fn load_release_env_values(&mut self) {
333        let env_path = self.workspace.join("release.env");
334        let Ok(entries) = read_env_entries(&env_path) else {
335            return;
336        };
337        self.play_json_path = entries
338            .get("GOOGLE_APPLICATION_CREDENTIALS")
339            .cloned()
340            .unwrap_or_default();
341        self.android_jks_path = entries.get("ANDROID_KEYSTORE").cloned().unwrap_or_default();
342        self.android_alias = entries
343            .get("ANDROID_KEYSTORE_ALIAS")
344            .cloned()
345            .unwrap_or_else(|| self.android_alias.clone());
346        self.app_store_key_path = entries
347            .get("APP_STORE_CONNECT_API_KEY_PATH")
348            .cloned()
349            .unwrap_or_default();
350        self.app_store_key_id = entries
351            .get("APP_STORE_CONNECT_KEY_ID")
352            .cloned()
353            .unwrap_or_default();
354        self.app_store_issuer_id = entries
355            .get("APP_STORE_CONNECT_ISSUER_ID")
356            .cloned()
357            .unwrap_or_default();
358        self.windows_pfx_path = entries
359            .get("WINDOWS_CERTIFICATE")
360            .cloned()
361            .unwrap_or_default();
362        self.azure_tenant_id = entries.get("AZURE_TENANT_ID").cloned().unwrap_or_default();
363        self.azure_client_id = entries.get("AZURE_CLIENT_ID").cloned().unwrap_or_default();
364        self.aws_profile = entries.get("AWS_PROFILE").cloned().unwrap_or_default();
365        self.aws_region = entries.get("AWS_REGION").cloned().unwrap_or_default();
366        self.aws_endpoint = entries
367            .get("AWS_ENDPOINT_URL_S3")
368            .cloned()
369            .unwrap_or_default();
370        self.aws_access_key_id = entries
371            .get("AWS_ACCESS_KEY_ID")
372            .cloned()
373            .unwrap_or_default();
374    }
375
376    fn save_env_value(&mut self, key: &str, value: &str) {
377        if value.trim().is_empty() {
378            self.status_message = format!("Skipped empty {key}");
379            return;
380        }
381        let env_path = self.workspace.join("release.env");
382        match upsert_env(&env_path, key, value.trim()) {
383            Ok(()) => self.status_message = format!("Saved {key} to {}", env_path.display()),
384            Err(err) => self.status_message = format!("Failed to save {key}: {err}"),
385        }
386    }
387
388    fn save_current_credentials(&mut self) {
389        match self.board {
390            PublishBoard::Android => {
391                let values = [
392                    (
393                        "GOOGLE_APPLICATION_CREDENTIALS",
394                        self.play_json_path.clone(),
395                    ),
396                    ("ANDROID_KEYSTORE", self.android_jks_path.clone()),
397                    ("ANDROID_KEYSTORE_ALIAS", self.android_alias.clone()),
398                    ("ANDROID_KEYSTORE_PASSWORD", self.android_password.clone()),
399                    ("ANDROID_KEY_PASSWORD", self.android_password.clone()),
400                ];
401                self.save_values(&values);
402            }
403            PublishBoard::Ios => {
404                let values = [
405                    (
406                        "APP_STORE_CONNECT_API_KEY_PATH",
407                        self.app_store_key_path.clone(),
408                    ),
409                    ("APP_STORE_CONNECT_KEY_ID", self.app_store_key_id.clone()),
410                    (
411                        "APP_STORE_CONNECT_ISSUER_ID",
412                        self.app_store_issuer_id.clone(),
413                    ),
414                ];
415                self.save_values(&values);
416            }
417            PublishBoard::Windows => {
418                let values = [
419                    ("WINDOWS_CERTIFICATE", self.windows_pfx_path.clone()),
420                    (
421                        "WINDOWS_CERTIFICATE_PASSWORD",
422                        self.windows_password.clone(),
423                    ),
424                    ("AZURE_TENANT_ID", self.azure_tenant_id.clone()),
425                    ("AZURE_CLIENT_ID", self.azure_client_id.clone()),
426                    (
427                        "MICROSOFT_STORE_CLIENT_SECRET",
428                        self.microsoft_secret.clone(),
429                    ),
430                ];
431                self.save_values(&values);
432            }
433            PublishBoard::S3 => {
434                let values = [
435                    ("AWS_PROFILE", self.aws_profile.clone()),
436                    ("AWS_REGION", self.aws_region.clone()),
437                    ("AWS_ENDPOINT_URL_S3", self.aws_endpoint.clone()),
438                    ("AWS_ACCESS_KEY_ID", self.aws_access_key_id.clone()),
439                    ("AWS_SECRET_ACCESS_KEY", self.aws_secret_access_key.clone()),
440                ];
441                self.save_values(&values);
442            }
443        }
444        self.start_snapshot_refresh();
445    }
446
447    fn save_values(&mut self, values: &[(&str, String)]) {
448        let env_path = self.workspace.join("release.env");
449        let saved = values
450            .iter()
451            .filter_map(|(key, value)| {
452                let value = value.trim();
453                (!value.is_empty()).then_some((*key, value.to_string()))
454            })
455            .collect::<Vec<_>>();
456        let result = saved
457            .iter()
458            .try_for_each(|(key, value)| upsert_env(&env_path, key, value));
459        match result {
460            Ok(()) => {
461                for (key, value) in saved {
462                    env::set_var(key, value);
463                }
464                self.status_message = format!("Saved release settings to {}", env_path.display())
465            }
466            Err(err) => self.status_message = format!("Failed to save release settings: {err}"),
467        }
468    }
469
470    fn open_file_picker(&mut self, purpose: FilePurpose) {
471        if self.native_file_dialog {
472            self.open_native_file_dialog(purpose);
473            return;
474        }
475        let current_dir = if self.project_dir.exists() {
476            self.project_dir.clone()
477        } else {
478            env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
479        };
480        self.file_picker = Some(FilePickerState::new(purpose, current_dir));
481    }
482
483    fn open_native_file_dialog(&mut self, purpose: FilePurpose) {
484        let mut dialog = rfd::FileDialog::new().set_title(purpose.title());
485        if self.project_dir.exists() {
486            dialog = dialog.set_directory(&self.project_dir);
487        }
488        if let Some((name, extensions)) = purpose.file_filter() {
489            dialog = dialog.add_filter(name, extensions);
490        }
491        match dialog.pick_file() {
492            Some(path) => {
493                self.selected_file = Some(FileSelection { purpose, path });
494                self.file_picker = None;
495                self.status_message = format!("Selected {}", purpose.title());
496            }
497            None => {
498                self.status_message = format!("No file selected for {}", purpose.title());
499            }
500        }
501    }
502
503    fn choose_file_entry(&mut self, index: usize) {
504        let Some(picker) = &mut self.file_picker else {
505            return;
506        };
507        picker.refresh();
508        picker.selected_index = index.min(picker.entries.len());
509        if index == 0 {
510            if let Some(parent) = picker.current_dir.parent() {
511                picker.current_dir = parent.to_path_buf();
512                picker.refresh();
513                picker.selected_index = 0;
514            }
515            return;
516        }
517        let Some(entry) = picker.entries.get(index.saturating_sub(1)).cloned() else {
518            return;
519        };
520        if entry.is_dir {
521            picker.current_dir = entry.path;
522            picker.refresh();
523            picker.selected_index = 0;
524        } else {
525            self.selected_file = Some(FileSelection {
526                purpose: picker.purpose,
527                path: entry.path,
528            });
529        }
530    }
531
532    fn handle_key(&mut self, code: &fission::KeyCode) -> bool {
533        if self.config_editor.is_some() && matches!(code, fission::KeyCode::Escape) {
534            self.config_editor = None;
535            return true;
536        }
537        if self.file_picker.is_some() {
538            return self.handle_file_picker_key(code);
539        }
540        match code {
541            fission::KeyCode::Left | fission::KeyCode::Up => {
542                self.previous_step();
543                true
544            }
545            fission::KeyCode::Right | fission::KeyCode::Down | fission::KeyCode::Enter => {
546                self.next_step();
547                true
548            }
549            _ => false,
550        }
551    }
552
553    fn handle_file_picker_key(&mut self, code: &fission::KeyCode) -> bool {
554        let Some(picker) = &mut self.file_picker else {
555            return false;
556        };
557        match code {
558            fission::KeyCode::Up => {
559                picker.selected_index = picker.selected_index.saturating_sub(1);
560                true
561            }
562            fission::KeyCode::Down => {
563                picker.selected_index = (picker.selected_index + 1).min(picker.entries.len());
564                true
565            }
566            fission::KeyCode::Enter | fission::KeyCode::Right => {
567                let index = picker.selected_index;
568                self.choose_file_entry(index);
569                true
570            }
571            fission::KeyCode::Escape | fission::KeyCode::Left => {
572                self.file_picker = None;
573                true
574            }
575            _ => false,
576        }
577    }
578
579    fn apply_selected_file(&mut self, action: FileAction) {
580        let Some(selection) = self.selected_file.take() else {
581            return;
582        };
583        let Some(dest_name) = selection.purpose.default_name(&selection.path) else {
584            return;
585        };
586        let folder = match selection.purpose {
587            FilePurpose::AppStoreKey => self.workspace.join("ios"),
588            FilePurpose::WindowsCertificate => self.workspace.join("windows"),
589            _ => self.workspace.clone(),
590        };
591        let result = match action {
592            FileAction::Reference => {
593                if path_is_inside_project(&selection.path, &self.project_dir) {
594                    Err(anyhow::anyhow!(
595                        "refusing to reference a secret file inside the project tree; copy or move it to {} instead",
596                        folder.display()
597                    ))
598                } else {
599                    Ok(selection.path.clone())
600                }
601            }
602            FileAction::Copy => {
603                copy_or_move_selected_file(&selection.path, &folder, &dest_name, false)
604            }
605            FileAction::Move => {
606                copy_or_move_selected_file(&selection.path, &folder, &dest_name, true)
607            }
608        };
609        match result {
610            Ok(path) => {
611                let value = path.display().to_string();
612                match selection.purpose {
613                    FilePurpose::PlayServiceJson => self.play_json_path = value.clone(),
614                    FilePurpose::AndroidKeystore => self.android_jks_path = value.clone(),
615                    FilePurpose::AppStoreKey => self.app_store_key_path = value.clone(),
616                    FilePurpose::WindowsCertificate => self.windows_pfx_path = value.clone(),
617                }
618                self.save_env_value(selection.purpose.env_key(), &value);
619                self.file_picker = None;
620            }
621            Err(err) => self.status_message = format!("File selection failed: {err}"),
622        }
623    }
624
625    fn generate_android_key(&mut self) {
626        if self.android_password.trim().is_empty() {
627            self.status_message = "Enter an Android keystore password first".to_string();
628            return;
629        }
630        let alias = if self.android_alias.trim().is_empty() {
631            sanitize_workspace_name(&self.app_name).replace('.', "-")
632        } else {
633            self.android_alias.clone()
634        };
635        let dest = self.workspace.join("upload-key.jks");
636        let task_dest = dest.clone();
637        let password = self.android_password.clone();
638        let task_alias = alias.clone();
639        self.start_task(PublishTaskKind::GenerateAndroidKey, move || {
640            let status = Command::new("keytool")
641                .arg("-genkeypair")
642                .arg("-v")
643                .arg("-keystore")
644                .arg(&task_dest)
645                .arg("-storepass")
646                .arg(&password)
647                .arg("-keypass")
648                .arg(&password)
649                .arg("-alias")
650                .arg(&task_alias)
651                .arg("-keyalg")
652                .arg("RSA")
653                .arg("-keysize")
654                .arg("2048")
655                .arg("-validity")
656                .arg("9125")
657                .arg("-dname")
658                .arg("CN=Fission Upload, OU=Fission Local Publish, O=Fission, L=Local, ST=Local, C=US")
659                .stdout(Stdio::piped())
660                .stderr(Stdio::piped())
661                .spawn()
662                .context("failed to run keytool")?
663                .wait_with_output()
664                .context("failed to wait for keytool")?;
665            let mut out = String::new();
666            out.push_str(&String::from_utf8_lossy(&status.stdout));
667            out.push_str(&String::from_utf8_lossy(&status.stderr));
668            if !status.status.success() {
669                anyhow::bail!("keytool failed\n{out}");
670            }
671            Ok(out)
672        });
673        self.android_jks_path = dest.display().to_string();
674        let values = [
675            ("ANDROID_KEYSTORE", self.android_jks_path.clone()),
676            ("ANDROID_KEYSTORE_ALIAS", alias),
677            ("ANDROID_KEYSTORE_PASSWORD", self.android_password.clone()),
678            ("ANDROID_KEY_PASSWORD", self.android_password.clone()),
679        ];
680        self.save_values(&values);
681    }
682
683    fn start_cli_task(&mut self, kind: PublishTaskKind) {
684        let task = self.task_request(kind);
685        self.start_task(kind, move || task.run())
686    }
687
688    fn skip_requirement(&mut self, id: String) {
689        match fission_command_release::skip_release_requirement(&self.project_dir, &id, true) {
690            Ok(()) => {
691                self.status_message = format!("Skipped recommended release check {id}");
692                self.start_snapshot_refresh();
693            }
694            Err(err) => self.status_message = format!("Failed to skip {id}: {err}"),
695        }
696    }
697
698    fn task_request(&self, kind: PublishTaskKind) -> PublishTaskRequest {
699        PublishTaskRequest {
700            kind,
701            project_dir: self.project_dir.clone(),
702            provider: self.provider,
703            target: self.target,
704            format: self.format,
705            artifact: if self.artifact_manifest.as_os_str().is_empty() {
706                None
707            } else {
708                Some(self.artifact_manifest.clone())
709            },
710            site: self.site.clone(),
711            deploy: self.deploy.clone(),
712            track: Some(self.track.clone()).filter(|value| !value.trim().is_empty()),
713            locales: self
714                .locales_input
715                .split(',')
716                .map(str::trim)
717                .filter(|value| !value.is_empty())
718                .map(str::to_string)
719                .collect(),
720        }
721    }
722
723    fn start_task<F>(&mut self, kind: PublishTaskKind, run: F)
724    where
725        F: FnOnce() -> Result<String> + Send + 'static,
726    {
727        if self
728            .task
729            .as_ref()
730            .is_some_and(|task| task.status() == TaskStatus::Running)
731        {
732            self.status_message = "A publish task is already running".to_string();
733            return;
734        }
735        let task = PublishTaskState::new(kind);
736        let shared = task.shared.clone();
737        thread::spawn(move || {
738            let result = run();
739            let mut data = shared.lock().expect("publish task lock poisoned");
740            match result {
741                Ok(output) => {
742                    data.status = TaskStatus::Ok;
743                    data.output = redact_output_lines(&output);
744                    if data.output.is_empty() {
745                        data.output.push("done".to_string());
746                    }
747                }
748                Err(err) => {
749                    data.status = TaskStatus::Failed;
750                    data.output = redact_output_lines(&err.to_string());
751                }
752            }
753            data.revision = data.revision.saturating_add(1);
754        });
755        self.task = Some(task);
756        self.status_message = format!("Started {}", kind.label());
757    }
758
759    fn poll_task(&mut self) -> bool {
760        let Some(task) = &self.task else {
761            return false;
762        };
763        let revision = task.revision();
764        if self.task_revision_seen == revision {
765            return false;
766        }
767        self.task_revision_seen = revision;
768        self.task_log = task.output();
769        if task.status() != TaskStatus::Running {
770            self.status_message = format!("{}: {}", task.kind.label(), task.status().label());
771            self.start_snapshot_refresh();
772        }
773        true
774    }
775
776    fn poll_snapshot_task(&mut self) -> bool {
777        let Some(task) = &self.snapshot_task else {
778            return false;
779        };
780        let revision = task.revision();
781        if self.snapshot_task_revision_seen == revision {
782            return false;
783        }
784        self.snapshot_task_revision_seen = revision;
785        if task.status() == TaskStatus::Running {
786            self.status_message = task.message();
787            return true;
788        }
789        let result = task.result();
790        self.snapshot_task = None;
791        self.snapshot_task_revision_seen = 0;
792        match result {
793            Some(Ok(result)) => {
794                self.apply_refresh_result(result);
795                if let Some(editor) = &mut self.config_editor {
796                    editor.status_message = "Saved and readiness refreshed.".to_string();
797                }
798            }
799            Some(Err(err)) => {
800                self.apply_refresh_error(err.clone());
801                if let Some(editor) = &mut self.config_editor {
802                    editor.status_message = format!("Saved, but readiness refresh failed: {err}");
803                }
804            }
805            None => self.status_message = "Preflight refresh finished without a result".to_string(),
806        }
807        true
808    }
809
810    fn poll_background_tasks(&mut self) -> bool {
811        let task_changed = self.poll_task();
812        let snapshot_changed = self.poll_snapshot_task();
813        task_changed || snapshot_changed
814    }
815
816    fn is_ready_to_publish(&self) -> bool {
817        Self::check_group_ready(&self.package_checks)
818            && Self::check_group_ready(&self.distribution_checks)
819            && Self::check_group_ready(&self.release_checks)
820            && self.publish_confirmation.trim() == self.app_id
821    }
822
823    fn check_group_ready(checks: &[UiCheck]) -> bool {
824        !checks.is_empty() && checks.iter().all(UiCheck::is_non_blocking)
825    }
826
827    fn next_step(&mut self) {
828        self.current_step = (self.current_step + 1).min(self.board.step_count());
829    }
830
831    fn previous_step(&mut self) {
832        self.current_step = self.current_step.saturating_sub(1).max(1);
833    }
834
835    fn go_to_step(&mut self, step: usize) {
836        self.current_step = step.clamp(1, self.board.step_count());
837    }
838
839    fn open_config_editor(&mut self, field: Option<String>) {
840        self.config_editor = Some(FissionTomlEditorState::load(&self.project_dir, field));
841    }
842
843    fn select_config_field(&mut self, field: String) {
844        if self.config_editor.is_none() {
845            self.open_config_editor(Some(field.clone()));
846        }
847        let value = read_fission_toml_field(&self.project_dir, &field).unwrap_or_default();
848        if let Some(editor) = &mut self.config_editor {
849            editor.selected_preset = field_specs()
850                .iter()
851                .position(|spec| spec.path == field)
852                .unwrap_or(editor.selected_preset);
853            editor.field_path = field;
854            editor.value = value;
855            editor.status_message = "Loaded current field value from fission.toml.".to_string();
856        }
857    }
858
859    fn apply_config_editor_field(&mut self) {
860        let Some((field_path, value)) = self
861            .config_editor
862            .as_ref()
863            .map(|editor| (editor.field_path.clone(), editor.value.clone()))
864        else {
865            return;
866        };
867        match apply_fission_toml_field(&self.project_dir, &field_path, &value) {
868            Ok(message) => {
869                if let Some(editor) = &mut self.config_editor {
870                    editor.status_message = format!("{message}. Refreshing readiness...");
871                }
872                self.start_snapshot_refresh();
873            }
874            Err(err) => {
875                if let Some(editor) = &mut self.config_editor {
876                    editor.status_message = format!("Failed to update fission.toml: {err}");
877                }
878            }
879        }
880    }
881}
882
883#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
884pub enum PublishBoard {
885    Android,
886    Ios,
887    Windows,
888    S3,
889}
890
891impl PublishBoard {
892    fn from_provider(provider: DistributionProvider) -> Self {
893        match provider {
894            DistributionProvider::AppStore => Self::Ios,
895            DistributionProvider::MicrosoftStore => Self::Windows,
896            DistributionProvider::S3 => Self::S3,
897            _ => Self::Android,
898        }
899    }
900
901    fn target(self) -> Target {
902        match self {
903            Self::Android => Target::Android,
904            Self::Ios => Target::Ios,
905            Self::Windows => Target::Windows,
906            Self::S3 => Target::Site,
907        }
908    }
909
910    fn format(self) -> PackageFormat {
911        match self {
912            Self::Android => PackageFormat::Aab,
913            Self::Ios => PackageFormat::Ipa,
914            Self::Windows => PackageFormat::Msix,
915            Self::S3 => PackageFormat::Static,
916        }
917    }
918
919    fn default_track(self) -> &'static str {
920        match self {
921            Self::Android => "internal",
922            Self::Ios => "testflight",
923            Self::Windows => "private",
924            Self::S3 => "",
925        }
926    }
927
928    fn step_count(self) -> usize {
929        7
930    }
931}
932
933#[derive(Clone, Debug, Eq, PartialEq)]
934pub struct UiCheck {
935    pub id: String,
936    pub severity: CheckSeverity,
937    pub status: CheckStatus,
938    pub summary: String,
939    pub details: Option<String>,
940    pub remediation: Vec<String>,
941}
942
943impl UiCheck {
944    fn failed(summary: impl Into<String>, details: impl Into<String>) -> Self {
945        Self {
946            id: "publish.ui.failed".to_string(),
947            severity: CheckSeverity::Error,
948            status: CheckStatus::Failed,
949            summary: summary.into(),
950            details: Some(details.into()),
951            remediation: Vec::new(),
952        }
953    }
954
955    fn is_non_blocking(&self) -> bool {
956        self.severity != CheckSeverity::Error || self.status == CheckStatus::Passed
957    }
958
959    fn needs_attention(&self) -> bool {
960        matches!(
961            self.status,
962            CheckStatus::Missing | CheckStatus::Failed | CheckStatus::Warning
963        )
964    }
965
966    fn action_hints(&self, board: PublishBoard, current_step: usize) -> Vec<PublishCheckAction> {
967        check_action_hints(self, board, current_step)
968    }
969}
970
971impl From<ReadinessCheck> for UiCheck {
972    fn from(value: ReadinessCheck) -> Self {
973        Self {
974            id: value.id,
975            severity: value.severity,
976            status: value.status,
977            summary: value.summary,
978            details: value.details,
979            remediation: value.remediation,
980        }
981    }
982}
983
984#[derive(Clone, Debug, Eq, PartialEq)]
985pub struct PublishCheckAction {
986    pub label: String,
987    pub kind: PublishCheckActionKind,
988    pub primary: bool,
989}
990
991impl PublishCheckAction {
992    fn primary(label: impl Into<String>, kind: PublishCheckActionKind) -> Self {
993        Self {
994            label: label.into(),
995            kind,
996            primary: true,
997        }
998    }
999
1000    fn secondary(label: impl Into<String>, kind: PublishCheckActionKind) -> Self {
1001        Self {
1002            label: label.into(),
1003            kind,
1004            primary: false,
1005        }
1006    }
1007}
1008
1009#[derive(Clone, Debug, Eq, PartialEq)]
1010pub enum PublishCheckActionKind {
1011    GoToStep(usize),
1012    OpenFilePicker(FilePurpose),
1013    OpenConfigEditor(String),
1014    SaveCredentials,
1015    GenerateAndroidKey,
1016    StartTask(PublishTaskKind),
1017    SkipRequirement(String),
1018    Refresh,
1019}
1020
1021fn check_action_hints(
1022    check: &UiCheck,
1023    board: PublishBoard,
1024    current_step: usize,
1025) -> Vec<PublishCheckAction> {
1026    if !check.needs_attention() {
1027        return Vec::new();
1028    }
1029    let haystack = check_search_text(check);
1030    let mut actions = Vec::new();
1031
1032    if mentions_any(
1033        &haystack,
1034        &[
1035            "google_application_credentials",
1036            "service account",
1037            "play service",
1038            "play store credential",
1039        ],
1040    ) {
1041        actions.push(PublishCheckAction::primary(
1042            "Select service JSON",
1043            PublishCheckActionKind::OpenFilePicker(FilePurpose::PlayServiceJson),
1044        ));
1045        actions.push(PublishCheckAction::secondary(
1046            "Credential step",
1047            PublishCheckActionKind::GoToStep(3),
1048        ));
1049    }
1050
1051    if mentions_any(
1052        &haystack,
1053        &["android_keystore", "keystore", "upload key", ".jks", "jks"],
1054    ) {
1055        actions.push(PublishCheckAction::primary(
1056            "Select JKS",
1057            PublishCheckActionKind::OpenFilePicker(FilePurpose::AndroidKeystore),
1058        ));
1059        actions.push(PublishCheckAction::secondary(
1060            "Generate key",
1061            PublishCheckActionKind::GenerateAndroidKey,
1062        ));
1063        actions.push(PublishCheckAction::secondary(
1064            "Signing step",
1065            PublishCheckActionKind::GoToStep(4),
1066        ));
1067    }
1068
1069    if mentions_any(
1070        &haystack,
1071        &[
1072            "app_store_connect_api_key_path",
1073            "app store connect",
1074            ".p8",
1075            "issuer id",
1076            "key id",
1077        ],
1078    ) {
1079        if mentions_any(&haystack, &[".p8", "api_key_path", "key path"]) {
1080            actions.push(PublishCheckAction::primary(
1081                "Select .p8 key",
1082                PublishCheckActionKind::OpenFilePicker(FilePurpose::AppStoreKey),
1083            ));
1084        }
1085        actions.push(PublishCheckAction::secondary(
1086            "Credential step",
1087            PublishCheckActionKind::GoToStep(4),
1088        ));
1089        actions.push(PublishCheckAction::secondary(
1090            "Save settings",
1091            PublishCheckActionKind::SaveCredentials,
1092        ));
1093    }
1094
1095    if mentions_any(
1096        &haystack,
1097        &[
1098            "windows_certificate",
1099            "certificate",
1100            ".pfx",
1101            ".p12",
1102            "signtool",
1103        ],
1104    ) {
1105        if mentions_any(&haystack, &["certificate", ".pfx", ".p12"]) {
1106            actions.push(PublishCheckAction::primary(
1107                "Select certificate",
1108                PublishCheckActionKind::OpenFilePicker(FilePurpose::WindowsCertificate),
1109            ));
1110        }
1111        actions.push(PublishCheckAction::secondary(
1112            "Signing step",
1113            PublishCheckActionKind::GoToStep(3),
1114        ));
1115    }
1116
1117    if mentions_any(
1118        &haystack,
1119        &[
1120            "azure_tenant_id",
1121            "azure_client_id",
1122            "microsoft_store_client_secret",
1123            "client secret",
1124            "tenant id",
1125            "seller id",
1126        ],
1127    ) {
1128        actions.push(PublishCheckAction::primary(
1129            "Store credential step",
1130            PublishCheckActionKind::GoToStep(4),
1131        ));
1132        actions.push(PublishCheckAction::secondary(
1133            "Save settings",
1134            PublishCheckActionKind::SaveCredentials,
1135        ));
1136    }
1137
1138    if mentions_any(
1139        &haystack,
1140        &[
1141            "aws_profile",
1142            "aws_region",
1143            "aws_access_key_id",
1144            "aws_secret_access_key",
1145            "s3",
1146            "bucket",
1147        ],
1148    ) {
1149        actions.push(PublishCheckAction::primary(
1150            "S3 settings step",
1151            PublishCheckActionKind::GoToStep(4),
1152        ));
1153        actions.push(PublishCheckAction::secondary(
1154            "Save settings",
1155            PublishCheckActionKind::SaveCredentials,
1156        ));
1157    }
1158
1159    if check.id.starts_with("release.package.")
1160        || mentions_any(
1161            &haystack,
1162            &[
1163                "artifact",
1164                "package",
1165                "rebuild",
1166                "build the",
1167                "manifest",
1168                "stale",
1169            ],
1170        )
1171    {
1172        actions.push(PublishCheckAction::primary(
1173            "Build artifact",
1174            PublishCheckActionKind::StartTask(PublishTaskKind::Package),
1175        ));
1176        actions.push(PublishCheckAction::secondary(
1177            "Build step",
1178            PublishCheckActionKind::GoToStep(match board {
1179                PublishBoard::Android => 6,
1180                PublishBoard::Ios => 6,
1181                PublishBoard::Windows => 6,
1182                PublishBoard::S3 => 5,
1183            }),
1184        ));
1185    }
1186
1187    if mentions_any(
1188        &haystack,
1189        &[
1190            "version code",
1191            "version_code",
1192            "build number",
1193            "build_number",
1194            "already been used",
1195            "release.build",
1196        ],
1197    ) {
1198        actions.push(PublishCheckAction::primary(
1199            "Bump build",
1200            PublishCheckActionKind::StartTask(PublishTaskKind::BumpBuild),
1201        ));
1202        actions.push(PublishCheckAction::secondary(
1203            "Rebuild artifact",
1204            PublishCheckActionKind::StartTask(PublishTaskKind::Package),
1205        ));
1206    }
1207
1208    if let Some(field) = config_field_for_check(check, board) {
1209        actions.push(PublishCheckAction::primary(
1210            format!("Configure {field}"),
1211            PublishCheckActionKind::OpenConfigEditor(field),
1212        ));
1213    } else if check.id.starts_with("release_config.") || check.id.starts_with("release_content.") {
1214        actions.push(PublishCheckAction::primary(
1215            "Open config editor",
1216            PublishCheckActionKind::OpenConfigEditor(String::new()),
1217        ));
1218    }
1219
1220    if actions.is_empty() {
1221        actions.push(PublishCheckAction::secondary(
1222            "Refresh after manual fix",
1223            PublishCheckActionKind::Refresh,
1224        ));
1225    }
1226
1227    if is_skippable_ui_check(check) {
1228        actions.push(PublishCheckAction::secondary(
1229            format!("Skip {}", short_check_action_label(&check.id)),
1230            PublishCheckActionKind::SkipRequirement(check.id.clone()),
1231        ));
1232    }
1233
1234    let actions = actions
1235        .into_iter()
1236        .filter(|action| !matches!(action.kind, PublishCheckActionKind::GoToStep(step) if step == current_step))
1237        .collect();
1238    dedupe_check_actions(actions)
1239}
1240
1241fn is_skippable_ui_check(check: &UiCheck) -> bool {
1242    check.severity != CheckSeverity::Error
1243        && matches!(
1244            check.status,
1245            CheckStatus::Missing | CheckStatus::Failed | CheckStatus::Warning
1246        )
1247}
1248
1249fn check_search_text(check: &UiCheck) -> String {
1250    let mut text = String::new();
1251    text.push_str(&check.id);
1252    text.push(' ');
1253    text.push_str(&check.summary);
1254    if let Some(details) = &check.details {
1255        text.push(' ');
1256        text.push_str(details);
1257    }
1258    for remediation in &check.remediation {
1259        text.push(' ');
1260        text.push_str(remediation);
1261    }
1262    text.to_ascii_lowercase()
1263}
1264
1265fn mentions_any(text: &str, needles: &[&str]) -> bool {
1266    needles.iter().any(|needle| text.contains(needle))
1267}
1268
1269fn short_check_action_label(id: &str) -> String {
1270    id.rsplit('.')
1271        .next()
1272        .filter(|value| !value.trim().is_empty())
1273        .unwrap_or(id)
1274        .replace('_', " ")
1275}
1276
1277fn dedupe_check_actions(actions: Vec<PublishCheckAction>) -> Vec<PublishCheckAction> {
1278    let mut deduped = Vec::new();
1279    for action in actions {
1280        if !deduped
1281            .iter()
1282            .any(|existing: &PublishCheckAction| existing.kind == action.kind)
1283        {
1284            deduped.push(action);
1285        }
1286    }
1287    deduped
1288}
1289
1290#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
1291pub enum ThemeMode {
1292    Light,
1293    #[default]
1294    Dark,
1295}
1296
1297#[derive(Clone, Debug, Eq, PartialEq)]
1298pub struct FilePickerState {
1299    pub purpose: FilePurpose,
1300    pub current_dir: PathBuf,
1301    pub entries: Vec<FileEntry>,
1302    pub selected_index: usize,
1303    pub error: Option<String>,
1304    pub truncated: bool,
1305}
1306
1307impl FilePickerState {
1308    fn new(purpose: FilePurpose, current_dir: PathBuf) -> Self {
1309        let mut state = Self {
1310            purpose,
1311            current_dir,
1312            entries: Vec::new(),
1313            selected_index: 0,
1314            error: None,
1315            truncated: false,
1316        };
1317        state.refresh();
1318        state
1319    }
1320
1321    fn refresh(&mut self) {
1322        self.error = None;
1323        self.truncated = false;
1324        let entries = match fs::read_dir(&self.current_dir) {
1325            Ok(entries) => entries,
1326            Err(err) => {
1327                self.entries.clear();
1328                self.error = Some(err.to_string());
1329                return;
1330            }
1331        };
1332        self.entries = entries
1333            .filter_map(Result::ok)
1334            .map(|entry| {
1335                let path = entry.path();
1336                FileEntry {
1337                    label: entry.file_name().to_string_lossy().to_string(),
1338                    is_dir: path.is_dir(),
1339                    path,
1340                }
1341            })
1342            .collect();
1343        self.entries
1344            .sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.label.cmp(&b.label)));
1345        if self.entries.len() > 200 {
1346            self.entries.truncate(200);
1347            self.truncated = true;
1348        }
1349        self.selected_index = self.selected_index.min(self.entries.len());
1350    }
1351}
1352
1353#[derive(Clone, Debug, Eq, PartialEq)]
1354pub struct FileEntry {
1355    pub label: String,
1356    pub is_dir: bool,
1357    pub path: PathBuf,
1358}
1359
1360#[derive(Clone, Debug, Eq, PartialEq)]
1361pub struct FileSelection {
1362    pub purpose: FilePurpose,
1363    pub path: PathBuf,
1364}
1365
1366#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1367pub enum FilePurpose {
1368    PlayServiceJson,
1369    AndroidKeystore,
1370    AppStoreKey,
1371    WindowsCertificate,
1372}
1373
1374impl FilePurpose {
1375    fn title(self) -> &'static str {
1376        match self {
1377            Self::PlayServiceJson => "Select Play service account JSON",
1378            Self::AndroidKeystore => "Select Android upload key JKS",
1379            Self::AppStoreKey => "Select App Store Connect .p8 key",
1380            Self::WindowsCertificate => "Select Windows signing certificate PFX",
1381        }
1382    }
1383
1384    fn env_key(self) -> &'static str {
1385        match self {
1386            Self::PlayServiceJson => "GOOGLE_APPLICATION_CREDENTIALS",
1387            Self::AndroidKeystore => "ANDROID_KEYSTORE",
1388            Self::AppStoreKey => "APP_STORE_CONNECT_API_KEY_PATH",
1389            Self::WindowsCertificate => "WINDOWS_CERTIFICATE",
1390        }
1391    }
1392
1393    fn default_name(self, selected: &Path) -> Option<String> {
1394        match self {
1395            Self::PlayServiceJson => Some("play-service-account.json".to_string()),
1396            Self::AndroidKeystore => Some("upload-key.jks".to_string()),
1397            Self::AppStoreKey | Self::WindowsCertificate => selected
1398                .file_name()
1399                .and_then(|value| value.to_str())
1400                .map(str::to_string),
1401        }
1402    }
1403
1404    fn file_filter(self) -> Option<(&'static str, &'static [&'static str])> {
1405        match self {
1406            Self::PlayServiceJson => Some(("JSON", &["json"])),
1407            Self::AndroidKeystore => Some(("Java keystore", &["jks", "keystore"])),
1408            Self::AppStoreKey => Some(("App Store Connect key", &["p8"])),
1409            Self::WindowsCertificate => Some(("Windows certificate", &["pfx", "p12"])),
1410        }
1411    }
1412}
1413
1414#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1415pub enum FileAction {
1416    Copy,
1417    Move,
1418    Reference,
1419}
1420
1421#[derive(Clone, Debug, PartialEq)]
1422struct PublishTaskRequest {
1423    kind: PublishTaskKind,
1424    project_dir: PathBuf,
1425    provider: DistributionProvider,
1426    target: Target,
1427    format: PackageFormat,
1428    artifact: Option<PathBuf>,
1429    site: String,
1430    deploy: Option<String>,
1431    track: Option<String>,
1432    locales: Vec<String>,
1433}
1434
1435impl PublishTaskRequest {
1436    fn run(self) -> Result<String> {
1437        match self.kind {
1438            PublishTaskKind::Package => {
1439                let path = package_silent(PackageOptions {
1440                    project_dir: self.project_dir,
1441                    target: self.target,
1442                    format: self.format,
1443                    release: true,
1444                    variant: None,
1445                    json: false,
1446                })?;
1447                Ok(format!("artifact manifest: {}", path.display()))
1448            }
1449            PublishTaskKind::DryRun | PublishTaskKind::Publish => {
1450                let dry_run = self.kind == PublishTaskKind::DryRun;
1451                publish_workflow(PublishWorkflowOptions {
1452                    project_dir: self.project_dir,
1453                    provider: self.provider,
1454                    target: Some(self.target),
1455                    format: Some(self.format),
1456                    artifact: self.artifact,
1457                    site: self.site,
1458                    deploy: self.deploy,
1459                    track: self.track,
1460                    locales: self.locales,
1461                    overwrite_remote: false,
1462                    dry_run,
1463                    yes: !dry_run,
1464                    json: false,
1465                })?;
1466                Ok("release workflow completed".to_string())
1467            }
1468            PublishTaskKind::BumpBuild => {
1469                fission_command_release::bump_release_build(
1470                    &self.project_dir,
1471                    Some(self.target),
1472                    1,
1473                    true,
1474                )?;
1475                Ok("release build bumped".to_string())
1476            }
1477            PublishTaskKind::GenerateAndroidKey => Ok("Android upload key generated".to_string()),
1478        }
1479    }
1480}
1481
1482#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1483pub enum PublishTaskKind {
1484    Package,
1485    DryRun,
1486    Publish,
1487    BumpBuild,
1488    GenerateAndroidKey,
1489}
1490
1491impl PublishTaskKind {
1492    fn label(self) -> &'static str {
1493        match self {
1494            Self::Package => "package build",
1495            Self::DryRun => "dry-run publish",
1496            Self::Publish => "publish",
1497            Self::BumpBuild => "build number bump",
1498            Self::GenerateAndroidKey => "Android upload key generation",
1499        }
1500    }
1501}
1502
1503#[derive(Clone, Debug)]
1504pub struct PublishTaskState {
1505    kind: PublishTaskKind,
1506    shared: Arc<Mutex<PublishTaskData>>,
1507}
1508
1509impl PublishTaskState {
1510    fn new(kind: PublishTaskKind) -> Self {
1511        Self {
1512            kind,
1513            shared: Arc::new(Mutex::new(PublishTaskData {
1514                status: TaskStatus::Running,
1515                revision: 1,
1516                output: vec![format!("Running {}...", kind.label())],
1517            })),
1518        }
1519    }
1520
1521    fn status(&self) -> TaskStatus {
1522        self.shared
1523            .lock()
1524            .expect("publish task lock poisoned")
1525            .status
1526    }
1527
1528    fn revision(&self) -> u64 {
1529        self.shared
1530            .lock()
1531            .expect("publish task lock poisoned")
1532            .revision
1533    }
1534
1535    fn output(&self) -> Vec<String> {
1536        self.shared
1537            .lock()
1538            .expect("publish task lock poisoned")
1539            .output
1540            .clone()
1541    }
1542}
1543
1544impl PartialEq for PublishTaskState {
1545    fn eq(&self, other: &Self) -> bool {
1546        self.kind == other.kind && self.revision() == other.revision()
1547    }
1548}
1549
1550#[derive(Clone, Debug)]
1551struct PublishTaskData {
1552    status: TaskStatus,
1553    revision: u64,
1554    output: Vec<String>,
1555}
1556
1557#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1558pub(super) enum TaskStatus {
1559    Running,
1560    Ok,
1561    Failed,
1562}
1563
1564impl TaskStatus {
1565    fn label(self) -> &'static str {
1566        match self {
1567            Self::Running => "running",
1568            Self::Ok => "passed",
1569            Self::Failed => "failed",
1570        }
1571    }
1572}
1573
1574#[fission_reducer(PublishNextStep)]
1575fn publish_next_step(state: &mut PublishUiState) {
1576    state.next_step();
1577}
1578
1579#[fission_reducer(PublishPreviousStep)]
1580fn publish_previous_step(state: &mut PublishUiState) {
1581    state.previous_step();
1582}
1583
1584#[fission_reducer(PublishGoToStep)]
1585fn publish_go_to_step(state: &mut PublishUiState, step: usize) {
1586    state.go_to_step(step);
1587}
1588
1589#[fission_reducer(PublishRefresh)]
1590fn publish_refresh(state: &mut PublishUiState) {
1591    state.start_snapshot_refresh();
1592}
1593
1594#[fission_reducer(PublishToggleTheme)]
1595fn publish_toggle_theme(state: &mut PublishUiState) {
1596    state.theme_mode = match state.theme_mode {
1597        ThemeMode::Light => ThemeMode::Dark,
1598        ThemeMode::Dark => ThemeMode::Light,
1599    };
1600}
1601
1602fn text_input_value(ctx: &ReducerContext<PublishUiState>) -> Option<String> {
1603    ctx.input
1604        .text_change()
1605        .map(|change| change.new_text.clone())
1606}
1607
1608#[fission_reducer(PublishSetTrack)]
1609fn publish_set_track(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1610    let Some(value) = text_input_value(ctx) else {
1611        return;
1612    };
1613    state.track = value;
1614    state.status_message =
1615        "Track updated; refresh preflight to re-check provider readiness.".into();
1616}
1617
1618#[fission_reducer(PublishSetLocales)]
1619fn publish_set_locales(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1620    let Some(value) = text_input_value(ctx) else {
1621        return;
1622    };
1623    state.locales_input = value;
1624    state.status_message =
1625        "Locales updated; refresh preflight to re-check release readiness.".into();
1626}
1627
1628#[fission_reducer(PublishSetPlayJson)]
1629fn publish_set_play_json(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1630    if let Some(value) = text_input_value(ctx) {
1631        state.play_json_path = value;
1632    }
1633}
1634
1635#[fission_reducer(PublishSetAndroidJks)]
1636fn publish_set_android_jks(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1637    if let Some(value) = text_input_value(ctx) {
1638        state.android_jks_path = value;
1639    }
1640}
1641
1642#[fission_reducer(PublishSetAndroidAlias)]
1643fn publish_set_android_alias(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1644    if let Some(value) = text_input_value(ctx) {
1645        state.android_alias = value;
1646    }
1647}
1648
1649#[fission_reducer(PublishSetAndroidPassword)]
1650fn publish_set_android_password(
1651    state: &mut PublishUiState,
1652    ctx: &mut ReducerContext<PublishUiState>,
1653) {
1654    if let Some(value) = text_input_value(ctx) {
1655        state.android_password = value;
1656    }
1657}
1658
1659#[fission_reducer(PublishSetAppStoreKeyPath)]
1660fn publish_set_app_store_key_path(
1661    state: &mut PublishUiState,
1662    ctx: &mut ReducerContext<PublishUiState>,
1663) {
1664    if let Some(value) = text_input_value(ctx) {
1665        state.app_store_key_path = value;
1666    }
1667}
1668
1669#[fission_reducer(PublishSetAppStoreKeyId)]
1670fn publish_set_app_store_key_id(
1671    state: &mut PublishUiState,
1672    ctx: &mut ReducerContext<PublishUiState>,
1673) {
1674    if let Some(value) = text_input_value(ctx) {
1675        state.app_store_key_id = value;
1676    }
1677}
1678
1679#[fission_reducer(PublishSetAppStoreIssuerId)]
1680fn publish_set_app_store_issuer_id(
1681    state: &mut PublishUiState,
1682    ctx: &mut ReducerContext<PublishUiState>,
1683) {
1684    if let Some(value) = text_input_value(ctx) {
1685        state.app_store_issuer_id = value;
1686    }
1687}
1688
1689#[fission_reducer(PublishSetWindowsPfx)]
1690fn publish_set_windows_pfx(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1691    if let Some(value) = text_input_value(ctx) {
1692        state.windows_pfx_path = value;
1693    }
1694}
1695
1696#[fission_reducer(PublishSetWindowsPassword)]
1697fn publish_set_windows_password(
1698    state: &mut PublishUiState,
1699    ctx: &mut ReducerContext<PublishUiState>,
1700) {
1701    if let Some(value) = text_input_value(ctx) {
1702        state.windows_password = value;
1703    }
1704}
1705
1706#[fission_reducer(PublishSetAzureTenant)]
1707fn publish_set_azure_tenant(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1708    if let Some(value) = text_input_value(ctx) {
1709        state.azure_tenant_id = value;
1710    }
1711}
1712
1713#[fission_reducer(PublishSetAzureClient)]
1714fn publish_set_azure_client(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1715    if let Some(value) = text_input_value(ctx) {
1716        state.azure_client_id = value;
1717    }
1718}
1719
1720#[fission_reducer(PublishSetMicrosoftSecret)]
1721fn publish_set_microsoft_secret(
1722    state: &mut PublishUiState,
1723    ctx: &mut ReducerContext<PublishUiState>,
1724) {
1725    if let Some(value) = text_input_value(ctx) {
1726        state.microsoft_secret = value;
1727    }
1728}
1729
1730#[fission_reducer(PublishSetAwsProfile)]
1731fn publish_set_aws_profile(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1732    if let Some(value) = text_input_value(ctx) {
1733        state.aws_profile = value;
1734    }
1735}
1736
1737#[fission_reducer(PublishSetAwsRegion)]
1738fn publish_set_aws_region(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1739    if let Some(value) = text_input_value(ctx) {
1740        state.aws_region = value;
1741    }
1742}
1743
1744#[fission_reducer(PublishSetAwsEndpoint)]
1745fn publish_set_aws_endpoint(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1746    if let Some(value) = text_input_value(ctx) {
1747        state.aws_endpoint = value;
1748    }
1749}
1750
1751#[fission_reducer(PublishSetAwsAccessKey)]
1752fn publish_set_aws_access_key(
1753    state: &mut PublishUiState,
1754    ctx: &mut ReducerContext<PublishUiState>,
1755) {
1756    if let Some(value) = text_input_value(ctx) {
1757        state.aws_access_key_id = value;
1758    }
1759}
1760
1761#[fission_reducer(PublishSetAwsSecretKey)]
1762fn publish_set_aws_secret_key(
1763    state: &mut PublishUiState,
1764    ctx: &mut ReducerContext<PublishUiState>,
1765) {
1766    if let Some(value) = text_input_value(ctx) {
1767        state.aws_secret_access_key = value;
1768    }
1769}
1770
1771#[fission_reducer(PublishSetConfirmation)]
1772fn publish_set_confirmation(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1773    if let Some(value) = text_input_value(ctx) {
1774        state.publish_confirmation = value;
1775    }
1776}
1777
1778#[fission_reducer(PublishSaveCredentials)]
1779fn publish_save_credentials(state: &mut PublishUiState) {
1780    state.save_current_credentials();
1781}
1782
1783#[fission_reducer(PublishOpenConfigEditor)]
1784fn publish_open_config_editor(state: &mut PublishUiState, field: String) {
1785    let field = (!field.trim().is_empty()).then_some(field);
1786    state.open_config_editor(field);
1787}
1788
1789#[fission_reducer(PublishCloseConfigEditor)]
1790fn publish_close_config_editor(state: &mut PublishUiState) {
1791    state.config_editor = None;
1792}
1793
1794#[fission_reducer(PublishSetConfigFieldPath)]
1795fn publish_set_config_field_path(
1796    state: &mut PublishUiState,
1797    ctx: &mut ReducerContext<PublishUiState>,
1798) {
1799    let Some(value) = text_input_value(ctx) else {
1800        return;
1801    };
1802    if let Some(editor) = &mut state.config_editor {
1803        editor.field_path = value;
1804    }
1805}
1806
1807#[fission_reducer(PublishSetConfigFieldValue)]
1808fn publish_set_config_field_value(
1809    state: &mut PublishUiState,
1810    ctx: &mut ReducerContext<PublishUiState>,
1811) {
1812    let Some(value) = text_input_value(ctx) else {
1813        return;
1814    };
1815    if let Some(editor) = &mut state.config_editor {
1816        editor.value = value;
1817    }
1818}
1819
1820#[fission_reducer(PublishSelectConfigField)]
1821fn publish_select_config_field(state: &mut PublishUiState, field: String) {
1822    state.select_config_field(field);
1823}
1824
1825#[fission_reducer(PublishApplyConfigField)]
1826fn publish_apply_config_field(state: &mut PublishUiState) {
1827    state.apply_config_editor_field();
1828}
1829
1830#[fission_reducer(PublishOpenFilePicker)]
1831fn publish_open_file_picker(state: &mut PublishUiState, purpose: FilePurpose) {
1832    state.open_file_picker(purpose);
1833}
1834
1835#[fission_reducer(PublishPickFileEntry)]
1836fn publish_pick_file_entry(state: &mut PublishUiState, index: usize) {
1837    state.choose_file_entry(index);
1838}
1839
1840#[fission_reducer(PublishApplyFile)]
1841fn publish_apply_file(state: &mut PublishUiState, action: FileAction) {
1842    state.apply_selected_file(action);
1843}
1844
1845#[fission_reducer(PublishCloseFilePicker)]
1846fn publish_close_file_picker(state: &mut PublishUiState) {
1847    state.file_picker = None;
1848    state.selected_file = None;
1849}
1850
1851#[fission_reducer(PublishStartTask)]
1852fn publish_start_task(state: &mut PublishUiState, kind: PublishTaskKind) {
1853    match kind {
1854        PublishTaskKind::Publish if !state.is_ready_to_publish() => {
1855            state.status_message =
1856                "Publish is locked until checks pass and the app id is typed exactly".to_string();
1857        }
1858        PublishTaskKind::GenerateAndroidKey => state.generate_android_key(),
1859        _ => state.start_cli_task(kind),
1860    }
1861}
1862
1863#[fission_reducer(PublishSkipRequirement)]
1864fn publish_skip_requirement(state: &mut PublishUiState, id: String) {
1865    state.skip_requirement(id);
1866}
1867
1868pub fn default_publish_options(project_dir: PathBuf) -> PublishUiOptions {
1869    let provider = read_project_config(&project_dir)
1870        .ok()
1871        .and_then(|project| {
1872            if project.targets.contains(&Target::Android) {
1873                Some(DistributionProvider::PlayStore)
1874            } else if project.targets.contains(&Target::Ios) {
1875                Some(DistributionProvider::AppStore)
1876            } else if project.targets.contains(&Target::Windows) {
1877                Some(DistributionProvider::MicrosoftStore)
1878            } else {
1879                None
1880            }
1881        })
1882        .unwrap_or(DistributionProvider::PlayStore);
1883    PublishUiOptions {
1884        project_dir,
1885        provider,
1886        target: None,
1887        format: None,
1888        artifact: None,
1889        site: "production".to_string(),
1890        deploy: None,
1891        track: None,
1892        locales: Vec::new(),
1893        screenshot: None,
1894        exit_after_render: false,
1895        width: None,
1896        height: None,
1897        native_file_dialog: false,
1898    }
1899}