-- ReaAssist Screen Reader Mode Installer -- https://reaassist.app -- -- Downloads the small ReaAssist bootstrap script plus the accessible -- Screen Reader Mode entry point, registers both as REAPER actions, sets -- Screen Reader Mode as the default, then launches it. ReaAssist fetches -- the rest on first run. -- -- To run: REAPER > Actions > Show action list > Load ReaScript, -- choose this file, then click Run. local INSTALL_REL = "Scripts/mbriggs-reaper/ReaAssist/" local EXT_NS = "reaassist" local RELEASE_COMMIT = "648636a0252b7605f3106b4d5a237d6d41aa39b2" local MAIN_URL = "https://raw.githubusercontent.com/michaelbriggsaudio/mbriggs-reaper/" .. RELEASE_COMMIT .. "/ReaAssist/ReaAssist.lua" local SR_URL = "https://raw.githubusercontent.com/michaelbriggsaudio/mbriggs-reaper/" .. RELEASE_COMMIT .. "/ReaAssist/ReaAssist_Screen_Reader_Mode.lua" local MAIN_SHA256 = "388a309ce251be4afd2876e68eca05c458fa02821b77b770542a2a3aa25a7547" local SR_SHA256 = "6ab04e252061abcc68625fe1badf442e0976f0312596ccc458b6c4e9e88fa1a7" local OSARA_URL = "https://osara.reaperaccessibility.com/snapshots/" local TITLE = "ReaAssist Screen Reader Mode Installer" local function die(msg) reaper.MB(msg, TITLE, 0) end local function confirm_install_start() local message = "ReaAssist Screen Reader Mode will now install and open.\n\n" .. "REAPER may be quiet while it downloads and verifies the files. " .. "This can take 20 seconds or more on some connections.\n\n" .. "Please wait until Screen Reader Mode opens or an error message appears.\n\n" .. "Choose OK to start the install, or Cancel to stop." local show_message = reaper.ShowMessageBox or reaper.MB if not show_message then return true end return show_message(message, TITLE, 1) == 1 end local function safe_url(url) url = tostring(url or "") if not url:match("^https?://") then return "" end return url:gsub("[^%w%-%.%_%~%:%/%?%#%[%]%@%!%&%*%+%,%;%=%{%}%%]", "") end local function open_url(url) url = safe_url(url) if url == "" then return false end if reaper.CF_ShellExecute then local ok, result = pcall(reaper.CF_ShellExecute, url) return ok and result ~= false end local os_name = reaper.GetOS and tostring(reaper.GetOS() or "") or "" local result, _, code if os_name:match("^Win") then result, _, code = os.execute('start "" "' .. url .. '"') elseif os_name:match("^OSX") or os_name:match("^macOS") then result, _, code = os.execute("open '" .. url .. "' >/dev/null 2>&1 &") else result, _, code = os.execute("xdg-open '" .. url .. "' >/dev/null 2>&1 &") end return result == true or result == 0 or code == 0 end local function copy_url(url) if not reaper.CF_SetClipboard then return false end return pcall(reaper.CF_SetClipboard, tostring(url or "")) end local function require_osara_first() if reaper.osara_outputMessage then return true end local message = "ReaAssist Screen Reader Mode requires the OSARA accessibility extension.\n\n" .. "Click OK to open the OSARA download page. Close Reaper, install OSARA and then run this installer again.\n\n" .. "Click Cancel if you did not intend to open the Screen Reader Mode." local choice = reaper.MB(message, TITLE, 1) if choice == 1 then copy_url(OSARA_URL) open_url(OSARA_URL) end return false end local function read_file(path) local f = io.open(path, "rb") if not f then return nil end local content = f:read("*a") f:close() return content end local function file_exists(path) local f = io.open(path, "rb") if not f then return false end f:close() return true end local function unix_quote(value) return "'" .. tostring(value):gsub("'", "'\\''") .. "'" end local function sha256_file(path) local os_name = reaper.GetOS() or "" local cmd if os_name:match("^Win") then cmd = 'certutil -hashfile "' .. path .. '" SHA256' elseif os_name:match("OSX") or os_name:match("macOS") then cmd = "/usr/bin/shasum -a 256 " .. unix_quote(path) else cmd = "sha256sum " .. unix_quote(path) end local output = reaper.ExecProcess(cmd, 30000) if not output then return nil, "the operating-system SHA-256 tool could not be launched" end local exit_code = tonumber(output:match("^(%-?%d+)")) if exit_code ~= 0 then return nil, "the operating-system SHA-256 tool failed (exit " .. tostring(exit_code or "unknown") .. ")" end for candidate in output:gmatch("%x+") do if #candidate == 64 then return candidate:lower() end end return nil, "the SHA-256 result could not be read" end local function verify_sha256(path, expected_sha256, label) local actual_sha256, err = sha256_file(path) if actual_sha256 == expected_sha256 then return true end local detail = err or ("expected " .. expected_sha256 .. " but received " .. tostring(actual_sha256)) return false, "Downloaded " .. label .. " failed immutable release verification. Nothing was installed.\n\n" .. detail end local function download_to(url, tmp_path, label) local os_name = reaper.GetOS() or "" local is_mac = os_name:match("OSX") or os_name:match("macOS") local curl_bin = is_mac and "/usr/bin/curl" or "curl" local cmd = string.format( '%s -fsSL --connect-timeout 10 --max-time 45 -o "%s" "%s"', curl_bin, tmp_path, url) local rc = reaper.ExecProcess(cmd, 60000) if not rc then return false, "Could not launch curl from REAPER (ExecProcess returned nil).\n\n" .. "Command attempted:\n" .. cmd end if not rc:match("^0\n") then local code = rc:match("^(%d+)") or "?" return false, string.format( "Download failed for %s (curl exit %s).\n\n%s", tostring(label or "file"), code, rc:sub(1, 300)) end return true end local function remove_if_present(path) if not file_exists(path) then return true end local ok, err = os.remove(path) if ok then return true end return false, tostring(err) end local function clean_transaction_files(entries) for _, entry in ipairs(entries) do remove_if_present(entry.tmp_path) end end local function restore_backups(entries, remove_installed) local errors = {} if remove_installed then for _, entry in ipairs(entries) do if entry.installed then local ok, err = remove_if_present(entry.final_path) if not ok then errors[#errors + 1] = "could not remove new " .. entry.label .. ": " .. err end end end end for index = #entries, 1, -1 do local entry = entries[index] if entry.backed_up then local ok, err = os.rename(entry.backup_path, entry.final_path) if not ok then errors[#errors + 1] = "could not restore " .. entry.label .. ": " .. tostring(err) .. " (backup retained at " .. entry.backup_path .. ")" end end end clean_transaction_files(entries) return table.concat(errors, "\n") end local function install_transaction(entries) for _, entry in ipairs(entries) do entry.had_existing = file_exists(entry.final_path) entry.backed_up = false entry.installed = false if file_exists(entry.backup_path) then local removed, remove_err = remove_if_present(entry.backup_path) if not removed then local rollback_err = restore_backups(entries, false) local message = "Could not remove stale backup for " .. entry.label .. ": " .. remove_err if rollback_err ~= "" then message = message .. "\n\n" .. rollback_err end return false, message end end if entry.had_existing then local ok, err = os.rename(entry.final_path, entry.backup_path) if not ok then local rollback_err = restore_backups(entries, false) local message = "Could not back up existing " .. entry.label .. ": " .. tostring(err) if rollback_err ~= "" then message = message .. "\n\n" .. rollback_err end return false, message end entry.backed_up = true end end for _, entry in ipairs(entries) do local ok, err = os.rename(entry.tmp_path, entry.final_path) if not ok then local rollback_err = restore_backups(entries, true) local message = "Could not move downloaded " .. entry.label .. " into place: " .. tostring(err) if rollback_err ~= "" then message = message .. "\n\n" .. rollback_err end return false, message end entry.installed = true end return true end local function register_action(entry) entry.registration_attempted = true local ok, action_id = pcall(reaper.AddRemoveReaScript, true, 0, entry.final_path, true) if not ok or not action_id or action_id == 0 then return nil end entry.action_id = action_id return action_id end local function rollback_transaction(entries) for _, entry in ipairs(entries) do if entry.registration_attempted then pcall(reaper.AddRemoveReaScript, false, 0, entry.final_path, true) end end local rollback_err = restore_backups(entries, true) for _, entry in ipairs(entries) do if entry.had_existing and file_exists(entry.final_path) then local ok, action_id = pcall(reaper.AddRemoveReaScript, true, 0, entry.final_path, true) if not ok or not action_id or action_id == 0 then local message = "could not restore the prior " .. entry.label .. " action registration" rollback_err = rollback_err == "" and message or (rollback_err .. "\n" .. message) end end end return rollback_err end local function commit_transaction(entries) for _, entry in ipairs(entries) do if entry.backed_up then remove_if_present(entry.backup_path) end end end if not require_osara_first() then return end if not confirm_install_start() then return end local target_dir = reaper.GetResourcePath() .. "/" .. INSTALL_REL reaper.RecursiveCreateDirectory(target_dir, 0) local main_path = target_dir .. "ReaAssist.lua" local sr_path = target_dir .. "ReaAssist_Screen_Reader_Mode.lua" local old_sr_path = target_dir .. "ReaAssist - Screen Reader Mode.lua" local main_tmp = main_path .. ".tmp" local sr_tmp = sr_path .. ".tmp" local ok, err = download_to(MAIN_URL, main_tmp, "ReaAssist.lua") if not ok then os.remove(main_tmp) die(err .. "\n\nCheck your network connection and try again.\n\n" .. "If the problem continues, you can install ReaAssist manually from " .. "https://github.com/michaelbriggsaudio/mbriggs-reaper") return end local content = read_file(main_tmp) local has_header = content and content:match("ReaAssist %- REAPER Smart Assistant") local has_version = content and content:match('VERSION%s*=%s*"%d+%.%d+%.%d+"') if not (has_header and has_version) then os.remove(main_tmp) die("Downloaded ReaAssist.lua does not look like ReaAssist. Try again later.") return end ok, err = verify_sha256(main_tmp, MAIN_SHA256, "ReaAssist.lua") if not ok then os.remove(main_tmp) die(err .. "\n\nPlease download the current installer again from https://reaassist.app") return end ok, err = download_to(SR_URL, sr_tmp, "ReaAssist_Screen_Reader_Mode.lua") if not ok then os.remove(main_tmp) os.remove(sr_tmp) die(err .. "\n\nCheck your network connection and try again.\n\n" .. "If the problem continues, you can install ReaAssist manually from " .. "https://github.com/michaelbriggsaudio/mbriggs-reaper") return end content = read_file(sr_tmp) if not (content and content:match("ReaAssist %- Screen Reader Mode") and content:find("CFG.VERSION", 1, true)) then os.remove(main_tmp) os.remove(sr_tmp) die("Downloaded Screen Reader Mode file does not look like ReaAssist. Try again later.") return end ok, err = verify_sha256(sr_tmp, SR_SHA256, "Screen Reader Mode") if not ok then os.remove(main_tmp) os.remove(sr_tmp) die(err .. "\n\nPlease download the current installer again from https://reaassist.app") return end local entries = { { label = "ReaAssist.lua", tmp_path = main_tmp, final_path = main_path, backup_path = main_path .. ".bak", }, { label = "Screen Reader Mode", tmp_path = sr_tmp, final_path = sr_path, backup_path = sr_path .. ".bak", }, } ok, err = install_transaction(entries) if not ok then die(err .. "\n\nThe previous ReaAssist installation was kept whenever it " .. "could be restored safely.") return end local main_action = register_action(entries[1]) if not main_action then local rollback_err = rollback_transaction(entries) local message = "ReaAssist could not be registered as a REAPER action, so " .. "the previous installation was restored." if rollback_err ~= "" then message = message .. "\n\n" .. rollback_err end die(message) return end local sr_action = register_action(entries[2]) if not sr_action then local rollback_err = rollback_transaction(entries) local message = "Screen Reader Mode could not be registered as a REAPER " .. "action, so the previous installation was restored." if rollback_err ~= "" then message = message .. "\n\n" .. rollback_err end die(message) return end commit_transaction(entries) pcall(reaper.AddRemoveReaScript, false, 0, old_sr_path, true) pcall(os.remove, old_sr_path) reaper.SetExtState(EXT_NS, "prefer_screen_reader", "1", true) reaper.Main_OnCommand(sr_action, 0)