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}