diff --git a/src/backend_win/asb_core.c b/src/backend_win/asb_core.c index 333e608..b4d2619 100644 --- a/src/backend_win/asb_core.c +++ b/src/backend_win/asb_core.c @@ -1356,12 +1356,236 @@ static int stage_marker_file(FILE *manifest_f, const wchar_t *staging, return 1; } +/* ==================================================================== + * Host locale / keyboard / timezone detection + Windows->Linux mapping + * + * At VM-create time we read the *current user's* Windows regional + * settings and translate them to the Ubuntu guest's equivalents, which + * firstboot applies (replacing the old hardcoded en_US / us / New_York). + * + * Language is GATED: Ubuntu's ISO only ships pre-installed UI + * translations for 8 languages (de, en, es, fr, it, pt, ru, zh). If the + * host language isn't one of those, the locale falls back to en_US.UTF-8 + * so the guest never lands on a half-translated desktop. Keyboard and + * timezone are NOT gated — every XKB layout and IANA zone is present in + * the squashfs offline, so they always match the host. + * ==================================================================== */ + +/* Map a BCP-47 tag (e.g. "en-GB", "zh-Hans-CN", "ja-JP") to a Linux + * locale string ("en_GB.UTF-8"). Returns the en_US.UTF-8 fallback when + * the primary language subtag is not one of the 8 preinstalled langs. */ +static void win_locale_to_linux(const wchar_t *bcp47, char *out, size_t out_sz) +{ + static const wchar_t *supported[] = { + L"de", L"en", L"es", L"fr", L"it", L"pt", L"ru", L"zh" + }; + /* Default region per language when the host tag carries no region. */ + static const struct { const wchar_t *lang; const char *loc; } defloc[] = { + { L"de", "de_DE" }, { L"en", "en_US" }, { L"es", "es_ES" }, + { L"fr", "fr_FR" }, { L"it", "it_IT" }, { L"pt", "pt_PT" }, + { L"ru", "ru_RU" }, { L"zh", "zh_CN" }, + }; + + strcpy_s(out, out_sz, "en_US.UTF-8"); + if (!bcp47 || !bcp47[0]) return; + + /* Split into up to 4 subtags. */ + wchar_t buf[64]; + wcsncpy_s(buf, 64, bcp47, _TRUNCATE); + wchar_t lang[16] = L"", script[16] = L"", region[16] = L""; + { + wchar_t *ctx = NULL; + wchar_t *tok = wcstok_s(buf, L"-", &ctx); + if (tok) { wcsncpy_s(lang, 16, tok, _TRUNCATE); _wcslwr_s(lang, 16); } + while ((tok = wcstok_s(NULL, L"-", &ctx)) != NULL) { + size_t l = wcslen(tok); + if (l == 4) { wcsncpy_s(script, 16, tok, _TRUNCATE); } /* script */ + else if (l == 2 || l == 3) { wcsncpy_s(region, 16, tok, _TRUNCATE); + _wcsupr_s(region, 16); } /* region */ + } + } + if (!lang[0]) return; + + /* Gate on the 8 supported languages. */ + int ok = 0; + for (int i = 0; i < (int)(sizeof(supported)/sizeof(supported[0])); i++) + if (_wcsicmp(lang, supported[i]) == 0) { ok = 1; break; } + if (!ok) return; /* keep en_US.UTF-8 fallback */ + + /* Chinese needs script->region resolution (zh_CN vs zh_TW etc.). */ + if (_wcsicmp(lang, L"zh") == 0) { + const char *loc = "zh_CN"; + if (region[0]) { + if (!_wcsicmp(region, L"TW")) loc = "zh_TW"; + else if (!_wcsicmp(region, L"HK")) loc = "zh_HK"; + else if (!_wcsicmp(region, L"SG")) loc = "zh_SG"; + else loc = "zh_CN"; + } else if (script[0]) { + loc = (!_wcsicmp(script, L"Hant")) ? "zh_TW" : "zh_CN"; + } + strcpy_s(out, out_sz, loc); + strcat_s(out, out_sz, ".UTF-8"); + return; + } + + if (region[0]) { + char lang_a[16], region_a[16]; + WideCharToMultiByte(CP_UTF8, 0, lang, -1, lang_a, sizeof(lang_a), NULL, NULL); + WideCharToMultiByte(CP_UTF8, 0, region, -1, region_a, sizeof(region_a), NULL, NULL); + snprintf(out, out_sz, "%s_%s.UTF-8", lang_a, region_a); + } else { + for (int i = 0; i < (int)(sizeof(defloc)/sizeof(defloc[0])); i++) + if (_wcsicmp(lang, defloc[i].lang) == 0) { + snprintf(out, out_sz, "%s.UTF-8", defloc[i].loc); + break; + } + } +} + +/* Map a Windows keyboard input-language id (LOWORD of an HKL) to an XKB + * layout. Never gated — all layouts ship in xkb-data. Fallback "us". */ +static void langid_to_xkb(WORD langid, char *out, size_t out_sz) +{ + static const struct { WORD id; const char *xkb; } map[] = { + { 0x0409, "us" }, { 0x0809, "gb" }, { 0x0c09, "us" }, /* en-AU->us */ + { 0x0407, "de" }, { 0x0c07, "at" }, { 0x0807, "ch" }, + { 0x040c, "fr" }, { 0x0c0c, "ca" }, { 0x080c, "be" }, { 0x100c, "ch" }, + { 0x0410, "it" }, { 0x0810, "ch" }, + { 0x040a, "es" }, { 0x080a, "latam" }, { 0x0c0a, "es" }, + { 0x0416, "br" }, { 0x0816, "pt" }, + { 0x0411, "jp" }, { 0x0412, "kr" }, + { 0x0804, "cn" }, { 0x0404, "tw" }, { 0x0c04, "cn" }, { 0x1404, "cn" }, + { 0x0419, "ru" }, { 0x0415, "pl" }, + { 0x0413, "nl" }, { 0x0813, "be" }, + { 0x041d, "se" }, { 0x0414, "no" }, { 0x0406, "dk" }, { 0x040b, "fi" }, + { 0x0405, "cz" }, { 0x040e, "hu" }, { 0x041f, "tr" }, + { 0x0401, "ara" }, { 0x040d, "il" }, { 0x041e, "th" }, + { 0x0422, "ua" }, { 0x0418, "ro" }, { 0x0408, "gr" }, + { 0x0402, "bg" }, { 0x041a, "hr" }, { 0x041b, "sk" }, { 0x0424, "si" }, + { 0x0425, "ee" }, { 0x0426, "lv" }, { 0x0427, "lt" }, + }; + strcpy_s(out, out_sz, "us"); + for (int i = 0; i < (int)(sizeof(map)/sizeof(map[0])); i++) + if (map[i].id == langid) { strcpy_s(out, out_sz, map[i].xkb); return; } + /* Fall back on the primary language id (low 10 bits) for unlisted + * sublang variants. */ + WORD prim = (WORD)(langid & 0x3ff); + for (int i = 0; i < (int)(sizeof(map)/sizeof(map[0])); i++) + if ((WORD)(map[i].id & 0x3ff) == prim) { strcpy_s(out, out_sz, map[i].xkb); return; } +} + +/* Map a Windows time-zone key name to an IANA zone. Never gated — all + * zones ship in tzdata. Fallback "Etc/UTC". Subset of CLDR windowsZones + * covering the common zones; unknown keys fall back to UTC. */ +static void win_tz_to_iana(const wchar_t *keyname, char *out, size_t out_sz) +{ + static const struct { const wchar_t *win; const char *iana; } map[] = { + { L"Dateline Standard Time", "Etc/GMT+12" }, + { L"Hawaiian Standard Time", "Pacific/Honolulu" }, + { L"Alaskan Standard Time", "America/Anchorage" }, + { L"Pacific Standard Time", "America/Los_Angeles" }, + { L"US Mountain Standard Time", "America/Phoenix" }, + { L"Mountain Standard Time", "America/Denver" }, + { L"Central Standard Time", "America/Chicago" }, + { L"Canada Central Standard Time", "America/Regina" }, + { L"Central America Standard Time", "America/Guatemala" }, + { L"Eastern Standard Time", "America/New_York" }, + { L"US Eastern Standard Time", "America/Indiana/Indianapolis" }, + { L"Atlantic Standard Time", "America/Halifax" }, + { L"SA Pacific Standard Time", "America/Bogota" }, + { L"SA Eastern Standard Time", "America/Cayenne" }, + { L"E. South America Standard Time","America/Sao_Paulo" }, + { L"Argentina Standard Time", "America/Buenos_Aires" }, + { L"Greenwich Standard Time", "Atlantic/Reykjavik" }, + { L"GMT Standard Time", "Europe/London" }, + { L"W. Europe Standard Time", "Europe/Berlin" }, + { L"Central Europe Standard Time", "Europe/Budapest" }, + { L"Romance Standard Time", "Europe/Paris" }, + { L"Central European Standard Time","Europe/Warsaw" }, + { L"W. Central Africa Standard Time","Africa/Lagos" }, + { L"FLE Standard Time", "Europe/Kiev" }, + { L"GTB Standard Time", "Europe/Bucharest" }, + { L"E. Europe Standard Time", "Europe/Chisinau" }, + { L"Russian Standard Time", "Europe/Moscow" }, + { L"Turkey Standard Time", "Europe/Istanbul" }, + { L"Israel Standard Time", "Asia/Jerusalem" }, + { L"Egypt Standard Time", "Africa/Cairo" }, + { L"South Africa Standard Time", "Africa/Johannesburg" }, + { L"Arabian Standard Time", "Asia/Dubai" }, + { L"Arab Standard Time", "Asia/Riyadh" }, + { L"Iran Standard Time", "Asia/Tehran" }, + { L"Pakistan Standard Time", "Asia/Karachi" }, + { L"India Standard Time", "Asia/Kolkata" }, + { L"Bangladesh Standard Time", "Asia/Dhaka" }, + { L"SE Asia Standard Time", "Asia/Bangkok" }, + { L"China Standard Time", "Asia/Shanghai" }, + { L"Singapore Standard Time", "Asia/Singapore" }, + { L"Taipei Standard Time", "Asia/Taipei" }, + { L"Tokyo Standard Time", "Asia/Tokyo" }, + { L"Korea Standard Time", "Asia/Seoul" }, + { L"W. Australia Standard Time", "Australia/Perth" }, + { L"AUS Central Standard Time", "Australia/Darwin" }, + { L"AUS Eastern Standard Time", "Australia/Sydney" }, + { L"E. Australia Standard Time", "Australia/Brisbane" }, + { L"New Zealand Standard Time", "Pacific/Auckland" }, + { L"UTC", "Etc/UTC" }, + }; + strcpy_s(out, out_sz, "Etc/UTC"); + if (!keyname || !keyname[0]) return; + for (int i = 0; i < (int)(sizeof(map)/sizeof(map[0])); i++) + if (_wcsicmp(keyname, map[i].win) == 0) { + strcpy_s(out, out_sz, map[i].iana); return; + } +} + +/* Read the current user's locale, keyboard layout, and timezone from the + * Windows host and translate to Linux equivalents. Best-effort: any + * failure leaves the corresponding output at its safe default. */ +static void detect_host_locale_settings(char *locale, size_t locale_sz, + char *xkb, size_t xkb_sz, + char *tz, size_t tz_sz) +{ + /* Locale (gated). */ + { + wchar_t name[LOCALE_NAME_MAX_LENGTH] = L""; + if (GetUserDefaultLocaleName(name, LOCALE_NAME_MAX_LENGTH) > 0) + win_locale_to_linux(name, locale, locale_sz); + else + strcpy_s(locale, locale_sz, "en_US.UTF-8"); + asb_log(L"Host locale: %s -> %hs", name[0] ? name : L"(none)", locale); + } + /* Keyboard (not gated). */ + { + HKL list[16]; + int n = GetKeyboardLayoutList(16, list); + WORD langid = 0x0409; + if (n > 0) langid = (WORD)((UINT_PTR)list[0] & 0xFFFF); + langid_to_xkb(langid, xkb, xkb_sz); + asb_log(L"Host keyboard: langid=0x%04x -> %hs", langid, xkb); + } + /* Timezone (not gated). */ + { + DYNAMIC_TIME_ZONE_INFORMATION dtzi; + DWORD r = GetDynamicTimeZoneInformation(&dtzi); + if (r != TIME_ZONE_ID_INVALID && dtzi.TimeZoneKeyName[0]) + win_tz_to_iana(dtzi.TimeZoneKeyName, tz, tz_sz); + else + strcpy_s(tz, tz_sz, "Etc/UTC"); + asb_log(L"Host timezone: %s -> %hs", + (r != TIME_ZONE_ID_INVALID) ? dtzi.TimeZoneKeyName : L"(invalid)", tz); + } +} + static int generate_vhdx_manifest_ubuntu(const wchar_t *manifest_path, const wchar_t *staging, const wchar_t *res_dir, BOOL ssh_enabled, const wchar_t *admin_user, - const char *admin_pw_hash) + const char *admin_pw_hash, + const char *host_locale, + const char *host_xkb, + const char *host_tz, + const wchar_t *vm_name) { wchar_t extras[MAX_PATH]; CreateDirectoryW(staging, NULL); @@ -1407,6 +1631,40 @@ static int generate_vhdx_manifest_ubuntu(const wchar_t *manifest_path, NULL, 0, L"/etc/appsandbox-ssh-enabled"); asb_log(L"ssh_enabled: staged /etc/appsandbox-ssh-enabled marker"); } + + /* Host-derived locale / keyboard / timezone. firstboot STEP 3 + 4 + * read these; absent markers fall back to en_US.UTF-8 / us / Etc/UTC. */ + if (host_locale && host_locale[0]) + n += stage_marker_file(f, staging, L"locale.marker", + host_locale, strlen(host_locale), + L"/etc/appsandbox-locale"); + if (host_xkb && host_xkb[0]) + n += stage_marker_file(f, staging, L"keyboard.marker", + host_xkb, strlen(host_xkb), + L"/etc/appsandbox-keyboard"); + if (host_tz && host_tz[0]) + n += stage_marker_file(f, staging, L"timezone.marker", + host_tz, strlen(host_tz), + L"/etc/appsandbox-timezone"); + asb_log(L"Host regional markers staged: locale=%hs keyboard=%hs tz=%hs", + host_locale ? host_locale : "(none)", + host_xkb ? host_xkb : "(none)", + host_tz ? host_tz : "(none)"); + + /* Hostname from the AppSandbox VM name (mirrors the Windows + * ComputerName path). The web UI already validates the name to a + * legal lowercase RFC-1123 hostname, so we write it verbatim; + * firstboot STEP 2 reads it and falls back to "ubuntu" if absent. */ + if (vm_name && vm_name[0]) { + char host_utf8[256]; + WideCharToMultiByte(CP_UTF8, 0, vm_name, -1, + host_utf8, sizeof(host_utf8), NULL, NULL); + n += stage_marker_file(f, staging, L"hostname.marker", + host_utf8, strlen(host_utf8), + L"/etc/appsandbox-hostname"); + asb_log(L"Linux hostname: staged /etc/appsandbox-hostname = %s", vm_name); + } + fclose(f); asb_log(L"Linux staging manifest written: %s (%d file(s))", manifest_path, n); return n; @@ -1871,10 +2129,20 @@ static DWORD WINAPI linux_create_thread(LPVOID param) } SecureZeroMemory(args->config.admin_pass, sizeof(args->config.admin_pass)); + /* Read the current user's Windows regional settings and translate to + * Linux. Language is gated to the 8 ISO-preinstalled languages + * (else en_US.UTF-8); keyboard + timezone always match the host. */ + char host_locale[64], host_xkb[32], host_tz[64]; + detect_host_locale_settings(host_locale, sizeof(host_locale), + host_xkb, sizeof(host_xkb), + host_tz, sizeof(host_tz)); + int n_staged = generate_vhdx_manifest_ubuntu(manifest, staging, res_dir, args->config.ssh_enabled, args->config.admin_user, - admin_pw_hash); + admin_pw_hash, + host_locale, host_xkb, host_tz, + args->config.name); SecureZeroMemory(admin_pw_hash, sizeof(admin_pw_hash)); if (n_staged < 0) { args->result = E_FAIL; diff --git a/src/backend_win/vm_display_idd.c b/src/backend_win/vm_display_idd.c index 8f5d683..01adee6 100644 --- a/src/backend_win/vm_display_idd.c +++ b/src/backend_win/vm_display_idd.c @@ -819,7 +819,6 @@ static DWORD WINAPI audio_recv_thread_proc(LPVOID param) IMMDevice *pDev = NULL; IAudioClient *pAC = NULL; IAudioRenderClient *pRC = NULL; - WAVEFORMATEX *mixfmt = NULL; WAVEFORMATEX *renderfmt = NULL; BYTE *scratch = NULL; UINT32 scratch_cap = 0; @@ -882,10 +881,13 @@ static DWORD WINAPI audio_recv_thread_proc(LPVOID param) hr = IMMDevice_Activate(pDev, &AUDIO_IID_IAudioClient, CLSCTX_ALL, NULL, (void **)&pAC); if (FAILED(hr)) { idd_log(d, L"Audio: Activate failed 0x%08lX.", hr); goto session_cleanup; } - hr = IAudioClient_GetMixFormat(pAC, &mixfmt); - if (FAILED(hr) || !mixfmt) { idd_log(d, L"Audio: GetMixFormat failed 0x%08lX.", hr); goto session_cleanup; } - - /* Build the source format exactly matching what the guest is sending. */ + /* Build the source format exactly matching what the guest is sending. + We deliberately do NOT call GetMixFormat: we never use the host's + mix format (Initialize runs with AUTOCONVERTPCM, so WASAPI converts + our renderfmt to whatever the endpoint needs). On some endpoints + (seen on AMD HD Audio) GetMixFormat returns + AUDCLNT_E_UNSUPPORTED_FORMAT for the configured default format, + which would needlessly kill the whole audio path. */ renderfmt = (WAVEFORMATEX *)CoTaskMemAlloc(sizeof(WAVEFORMATEX)); if (!renderfmt) goto session_cleanup; ZeroMemory(renderfmt, sizeof(WAVEFORMATEX)); @@ -975,7 +977,6 @@ session_cleanup: if (pAC) { IAudioClient_Release(pAC); pAC = NULL; } if (pDev) { IMMDevice_Release(pDev); pDev = NULL; } if (pEnum) { IMMDeviceEnumerator_Release(pEnum); pEnum = NULL; } - if (mixfmt) { CoTaskMemFree(mixfmt); mixfmt = NULL; } if (renderfmt) { CoTaskMemFree(renderfmt); renderfmt = NULL; } if (d->audio_socket != INVALID_SOCKET) { diff --git a/tools/iso-patch/ubuntu_vhdx.c b/tools/iso-patch/ubuntu_vhdx.c index 0d64c1c..bf192e1 100644 --- a/tools/iso-patch/ubuntu_vhdx.c +++ b/tools/iso-patch/ubuntu_vhdx.c @@ -1114,31 +1114,64 @@ static void plant_firstboot_service(ext4_writer_t *ew) " echo \"SKIP STEP 1: grub-install binary not found in rootfs\"\n" "fi\n" "\n" - "# --- STEP 2: Hostname ---\n" + "# --- STEP 2: Hostname (from host marker = AppSandbox VM name) ---\n" + "# Host staged /etc/appsandbox-hostname with the VM name (already\n" + "# validated to a legal lowercase hostname by the create UI). Falls\n" + "# back to 'ubuntu' if the marker is absent.\n" "echo \"==== STEP 2: hostname ====\"\n" - "echo ubuntu > /etc/hostname && echo \"OK: /etc/hostname\" || echo \"FAIL\"\n" - "command -v hostnamectl >/dev/null 2>&1 && hostnamectl set-hostname ubuntu || echo \"SKIP hostnamectl\"\n" + "HN=ubuntu\n" + "if [ -s /etc/appsandbox-hostname ]; then HN=$(tr -d '\\r\\n[:space:]' < /etc/appsandbox-hostname); fi\n" + "[ -n \"$HN\" ] || HN=ubuntu\n" + "echo \"$HN\" > /etc/hostname && echo \"OK: /etc/hostname=$HN\" || echo \"FAIL\"\n" + "command -v hostnamectl >/dev/null 2>&1 && hostnamectl set-hostname \"$HN\" || echo \"SKIP hostnamectl\"\n" "if [ -f /etc/hosts ]; then\n" - " sed -i 's/^127\\.0\\.1\\.1.*/127.0.1.1\\tubuntu/' /etc/hosts || true\n" - " grep -q '^127.0.1.1' /etc/hosts || printf '127.0.1.1\\tubuntu\\n' >> /etc/hosts\n" + " sed -i \"s/^127\\.0\\.1\\.1.*/127.0.1.1\\t$HN/\" /etc/hosts || true\n" + " grep -q '^127.0.1.1' /etc/hosts || printf '127.0.1.1\\t%s\\n' \"$HN\" >> /etc/hosts\n" "fi\n" + "rm -f /etc/appsandbox-hostname 2>/dev/null || true\n" "\n" - "# --- STEP 3: Timezone (US Eastern) ---\n" + "# --- STEP 3: Timezone (from host marker, fallback Etc/UTC) ---\n" + "# Host staged /etc/appsandbox-timezone with the IANA zone derived\n" + "# from the current Windows user's timezone. All zones ship in\n" + "# tzdata so any value resolves offline.\n" "echo \"==== STEP 3: timezone ====\"\n" - "ln -sf /usr/share/zoneinfo/America/New_York /etc/localtime && echo \"OK: localtime\" || echo \"FAIL\"\n" - "echo America/New_York > /etc/timezone || true\n" + "TZ=Etc/UTC\n" + "if [ -s /etc/appsandbox-timezone ]; then TZ=$(tr -d '\\r\\n[:space:]' < /etc/appsandbox-timezone); fi\n" + "if [ ! -e \"/usr/share/zoneinfo/$TZ\" ]; then echo \"WARN: zoneinfo $TZ missing, using Etc/UTC\"; TZ=Etc/UTC; fi\n" + "ln -sf \"/usr/share/zoneinfo/$TZ\" /etc/localtime && echo \"OK: localtime=$TZ\" || echo \"FAIL: localtime\"\n" + "echo \"$TZ\" > /etc/timezone || true\n" + "rm -f /etc/appsandbox-timezone 2>/dev/null || true\n" "\n" - "# --- STEP 4: Locale + keyboard ---\n" + "# --- STEP 4: Locale + keyboard (from host markers, gated fallbacks) ---\n" + "# Host staged /etc/appsandbox-locale (already gated to the 8\n" + "# ISO-preinstalled languages, else en_US.UTF-8) and\n" + "# /etc/appsandbox-keyboard (XKB layout). If locale-gen rejects the\n" + "# locale we fall back to en_US.UTF-8 so the desktop is never broken.\n" "echo \"==== STEP 4: locale + keyboard ====\"\n" - "echo 'LANG=en_US.UTF-8' > /etc/default/locale || true\n" - "command -v locale-gen >/dev/null 2>&1 && locale-gen en_US.UTF-8 >/dev/null 2>&1 || echo \"SKIP locale-gen\"\n" - "cat > /etc/default/keyboard <<'EOF'\n" + "LOC=en_US.UTF-8\n" + "if [ -s /etc/appsandbox-locale ]; then LOC=$(tr -d '\\r\\n[:space:]' < /etc/appsandbox-locale); fi\n" + "echo \"LANG=$LOC\" > /etc/default/locale || true\n" + "if command -v locale-gen >/dev/null 2>&1; then\n" + " if ! locale-gen \"$LOC\" >/dev/null 2>&1; then\n" + " echo \"WARN: locale-gen $LOC failed, falling back to en_US.UTF-8\"\n" + " LOC=en_US.UTF-8; echo \"LANG=$LOC\" > /etc/default/locale\n" + " locale-gen en_US.UTF-8 >/dev/null 2>&1 || true\n" + " fi\n" + " echo \"OK: locale=$LOC\"\n" + "else\n" + " echo \"SKIP locale-gen\"\n" + "fi\n" + "KB=us\n" + "if [ -s /etc/appsandbox-keyboard ]; then KB=$(tr -d '\\r\\n[:space:]' < /etc/appsandbox-keyboard); fi\n" + "cat > /etc/default/keyboard </dev/null || true\n" "\n" "# --- STEP 5: User account (from create-VM modal, falls back to test) ---\n" "# Host writes the chosen username + glibc $6$ hash into these\n" @@ -1604,6 +1637,11 @@ static void plant_firstboot_service(ext4_writer_t *ew) "echo \"-- grub cmdline drop-in --\"\n" "_check_file /etc/default/grub.d/99-appsandbox-no-efifb.cfg\n" "_check_glob '/opt/appsandbox/local-apt/pool/main/*'\n" + "echo \"-- regional settings (from host) --\"\n" + "echo \" hostname: $(cat /etc/hostname 2>/dev/null)\"\n" + "echo \" locale: $(grep -h LANG= /etc/default/locale 2>/dev/null | head -1)\"\n" + "echo \" keyboard: $(grep -h XKBLAYOUT /etc/default/keyboard 2>/dev/null | head -1)\"\n" + "echo \" timezone: $(cat /etc/timezone 2>/dev/null)\"\n" "set -x\n" "\n" "# --- STEP 99: Mark done + reboot ---\n" diff --git a/web/app.js b/web/app.js index d997eed..55a4567 100644 --- a/web/app.js +++ b/web/app.js @@ -111,7 +111,15 @@ function applyOsTypeUI() { hidden so the cloud-image code path can be revived without re-adding the markup. */ for (var k = 0; k < needsLinuxVersion.length; k++) needsLinuxVersion[k].style.display = 'none'; + /* Swap the default VM name between OS conventions, but only when the + field still holds the *other* OS's untouched default — never clobber a + name the user typed. Linux hostnames must be lowercase. */ + var nameEl = document.getElementById('vm-name'); + if (isLinux && nameEl.value === 'MyAppSandbox') nameEl.value = 'myappsandbox'; + else if (!isLinux && nameEl.value === 'myappsandbox') nameEl.value = 'MyAppSandbox'; revalidateVmName(); + revalidateUsername(); + revalidatePassword(); updateCreateButtons(); } @@ -351,9 +359,16 @@ function revalidateVmName() { document.getElementById('vm-name-warn').textContent = validateVmName(name) || ''; } document.getElementById('vm-name').addEventListener('input', revalidateVmName); -document.getElementById('admin-user').addEventListener('input', function() { - document.getElementById('admin-user-warn').textContent = validateUsername(this.value.trim()) || ''; -}); + +function revalidateUsername() { + var u = document.getElementById('admin-user').value.trim(); + document.getElementById('admin-user-warn').textContent = validateUsername(u) || ''; +} +function revalidatePassword() { + var p = document.getElementById('admin-pass').value; + document.getElementById('admin-pass-warn').textContent = validatePassword(p) || ''; +} +document.getElementById('admin-user').addEventListener('input', revalidateUsername); function checkPasswordMatch() { var pass = document.getElementById('admin-pass').value; @@ -370,7 +385,10 @@ function checkPasswordMatch() { confirm.classList.add('pass-mismatch'); } } -document.getElementById('admin-pass').addEventListener('input', checkPasswordMatch); +document.getElementById('admin-pass').addEventListener('input', function() { + checkPasswordMatch(); + revalidatePassword(); +}); document.getElementById('admin-confirm').addEventListener('input', checkPasswordMatch); checkPasswordMatch(); @@ -424,15 +442,19 @@ function clearCreateForm() { updateCreateButtons(); } +/* VM name / hostname validation. Per-guest-OS rules, keyed off the + selected OS Type (on a macOS host the dropdown is locked to 'macOS', + so osType is an accurate guest discriminator on all hosts). */ function validateVmName(name) { if (!name) return 'VM name is required.'; var osSelect = document.getElementById('os-type'); var osType = osSelect ? osSelect.value : 'Windows'; - if (hostBridge.isMac) { + if (osType === 'macOS') { if (name.length > 63) return 'VM name cannot exceed 63 characters (macOS LocalHostName limit).'; } else if (osType === 'Linux') { if (name.length > 63) return 'VM name cannot exceed 63 characters (Linux hostname limit).'; - } else { + if (/[A-Z]/.test(name)) return 'Linux hostname must be lowercase.'; + } else { /* Windows */ if (name.length > 15) return 'VM name cannot exceed 15 characters (NetBIOS limit).'; } if (/[^a-zA-Z0-9-]/.test(name)) return 'VM name can only contain letters, digits, and hyphens.'; @@ -448,8 +470,23 @@ function validateVmName(name) { return null; } +/* Username validation. Per-guest-OS rules keyed off osType. Each branch + is explicit so it's clear which OS's account rules apply. */ function validateUsername(name) { if (!name) return 'Username is required.'; + var osSelect = document.getElementById('os-type'); + var osType = osSelect ? osSelect.value : 'Windows'; + if (osType === 'Linux') { + /* Ubuntu useradd/adduser: lowercase, start with a letter or + underscore, then [a-z0-9_-], max 32 chars. */ + if (name.length > 32) return 'Username cannot exceed 32 characters (Linux limit).'; + if (!/^[a-z_][a-z0-9_-]*$/.test(name)) + return 'Lowercase alphanumeric only.'; + return null; + } + /* macOS and Windows: keep the existing Windows-account ruleset. + (macOS-specific shortname rules are not yet verified; treated the + same as Windows for now — see validatePassword note.) */ if (name.length > 20) return 'Username cannot exceed 20 characters.'; if (/["\\/\[\]:;|=,+*?<>]/.test(name)) return 'Username contains invalid characters.'; if (/^[.\s]+$/.test(name)) return 'Username cannot be only dots or spaces.'; @@ -461,12 +498,33 @@ function validateUsername(name) { return null; } +/* Password validation. Per-guest-OS rules keyed off osType. + - Linux: Ubuntu accepts ALL characters via the host's $6$ hash path + (usermod -p bypasses pwquality), so the only limits are non-empty + and a sane byte ceiling. + - macOS / Windows: no extra content rule enforced here today. */ +function validatePassword(pass) { + var osSelect = document.getElementById('os-type'); + var osType = osSelect ? osSelect.value : 'Windows'; + if (osType === 'Linux') { + if (!pass) return 'Password is required.'; + /* UTF-8 byte length (encodeURIComponent escapes multibyte). */ + var bytes = unescape(encodeURIComponent(pass)).length; + if (bytes > 255) return 'Password is too long (max 255 bytes).'; + return null; + } + /* macOS / Windows: no additional constraints today. */ + return null; +} + function onCreateVm() { var cfg = gatherConfig(); var nameErr = validateVmName(cfg.name); if (nameErr) { sendCmd('log', { message: nameErr }); return; } var userErr = validateUsername(cfg.adminUser); if (userErr) { sendCmd('log', { message: userErr }); return; } + var passErr = validatePassword(cfg.adminPass); + if (passErr) { sendCmd('log', { message: passErr }); return; } if (cfg.adminPass !== cfg.adminConfirm) { sendCmd('log', { message: 'Passwords do not match.' }); return; @@ -514,6 +572,7 @@ function openCreateModal() { /* Clear validation state */ document.getElementById('vm-name-warn').textContent = ''; document.getElementById('admin-user-warn').textContent = ''; + document.getElementById('admin-pass-warn').textContent = ''; checkPasswordMatch(); onNetModeChange(); applyOsTypeUI(); /* fires updateCreateButtons + revalidateVmName */ diff --git a/web/index.html b/web/index.html index 5d65dc1..0c6983f 100644 --- a/web/index.html +++ b/web/index.html @@ -63,12 +63,6 @@