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