Skip to content

Commit 3024920

Browse files
authored
fix: TimeZoneNotFoundException on old windows (#20)
* add NLS check and show error if converter not registed. * add windows/macos test and skip NLS test without windows
1 parent d52ca56 commit 3024920

24 files changed

+373
-155
lines changed

.github/workflows/test.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ on:
1111

1212
jobs:
1313
test:
14-
runs-on: ubuntu-latest
14+
strategy:
15+
matrix:
16+
os: [ubuntu-latest, macos-latest, windows-latest]
17+
runs-on: ${{ matrix.os }}
1518
steps:
1619
- name: Checkout repository
1720
uses: actions/checkout@v4

BlazorLocalTime.sln

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11

22
Microsoft Visual Studio Solution File, Format Version 12.00
3+
# Visual Studio Version 17
4+
VisualStudioVersion = 17.14.36221.1
5+
MinimumVisualStudioVersion = 10.0.40219.1
36
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorLocalTime", "src\BlazorLocalTime\BlazorLocalTime.csproj", "{10BD6EFA-52CE-479D-9086-BACD746F8F36}"
47
EndProject
58
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorLocalTimeTest", "tests\BlazorLocalTimeTest\BlazorLocalTimeTest.csproj", "{72BC6136-58FA-4856-A568-0D44942DCC5C}"
69
EndProject
710
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorLocalTimeSample", "example\BlazorLocalTimeSample\BlazorLocalTimeSample.csproj", "{4E398EDD-6CFE-44B3-899E-A7325A85CE55}"
811
EndProject
12+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorLocalTimeTest.Nls", "tests\BlazorLocalTimeTest.Nls\BlazorLocalTimeTest.Nls.csproj", "{4368AD9B-B26A-4BA5-B5A5-0C1FF66F98BF}"
13+
EndProject
14+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
15+
EndProject
16+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{4F9DEAE5-078F-E77A-2E4A-FEB6FFE226FF}"
17+
EndProject
18+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
19+
EndProject
920
Global
1021
GlobalSection(SolutionConfigurationPlatforms) = preSolution
1122
Debug|Any CPU = Debug|Any CPU
@@ -24,5 +35,21 @@ Global
2435
{4E398EDD-6CFE-44B3-899E-A7325A85CE55}.Debug|Any CPU.Build.0 = Debug|Any CPU
2536
{4E398EDD-6CFE-44B3-899E-A7325A85CE55}.Release|Any CPU.ActiveCfg = Release|Any CPU
2637
{4E398EDD-6CFE-44B3-899E-A7325A85CE55}.Release|Any CPU.Build.0 = Release|Any CPU
38+
{4368AD9B-B26A-4BA5-B5A5-0C1FF66F98BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
39+
{4368AD9B-B26A-4BA5-B5A5-0C1FF66F98BF}.Debug|Any CPU.Build.0 = Debug|Any CPU
40+
{4368AD9B-B26A-4BA5-B5A5-0C1FF66F98BF}.Release|Any CPU.ActiveCfg = Release|Any CPU
41+
{4368AD9B-B26A-4BA5-B5A5-0C1FF66F98BF}.Release|Any CPU.Build.0 = Release|Any CPU
42+
EndGlobalSection
43+
GlobalSection(SolutionProperties) = preSolution
44+
HideSolutionNode = FALSE
45+
EndGlobalSection
46+
GlobalSection(NestedProjects) = preSolution
47+
{10BD6EFA-52CE-479D-9086-BACD746F8F36} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
48+
{72BC6136-58FA-4856-A568-0D44942DCC5C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
49+
{4E398EDD-6CFE-44B3-899E-A7325A85CE55} = {4F9DEAE5-078F-E77A-2E4A-FEB6FFE226FF}
50+
{4368AD9B-B26A-4BA5-B5A5-0C1FF66F98BF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
51+
EndGlobalSection
52+
GlobalSection(ExtensibilityGlobals) = postSolution
53+
SolutionGuid = {11F3F046-8555-4A1A-A613-01EDEB5344A3}
2754
EndGlobalSection
2855
EndGlobal
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace BlazorLocalTime;
2+
3+
/// <summary>
4+
/// Represents the configuration settings for local time operations in a Blazor application.
5+
/// </summary>
6+
public class BlazorLocalTimeConfiguration
7+
{
8+
/// <summary>
9+
/// Gets the <see cref="TimeProvider"/> used to supply the current time.
10+
/// </summary>
11+
public TimeProvider TimeProvider { get; set; } = TimeProvider.System;
12+
13+
/// <summary>
14+
/// Specifies a function to convert IANA time zones to Windows time zones.
15+
/// For example, `TZConvert.IanaToWindows` from TimeZoneConverter(https://github.com/mattjohnsonpint/TimeZoneConverter) can be used.
16+
/// This is required on operating systems where ICU is unavailable(such as Windows Server 2016), but need not be specified on others.
17+
/// For details, see https://github.com/arika0093/BlazorLocalTime/issues/19
18+
/// </summary>
19+
public Func<string, string>? IanaToWindows { get; set; } = null;
20+
}

src/BlazorLocalTime/BlazorLocalTimeExtension.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,34 @@ public static IServiceCollection AddBlazorLocalTimeService(
3030
this IServiceCollection services,
3131
TimeProvider timeProvider
3232
)
33+
{
34+
return AddBlazorLocalTimeService(
35+
services,
36+
option =>
37+
{
38+
option.TimeProvider = timeProvider;
39+
}
40+
);
41+
}
42+
43+
/// <summary>
44+
/// Adds the BlazorLocalTime service to the service collection with configurable option.
45+
/// </summary>
46+
/// <param name="services">The service collection.</param>
47+
/// <param name="configuration">An action to configure BlazorLocalTime options.</param>
48+
/// <returns>The updated service collection.</returns>
49+
public static IServiceCollection AddBlazorLocalTimeService(
50+
this IServiceCollection services,
51+
Action<BlazorLocalTimeConfiguration> configuration
52+
)
3353
{
3454
services.AddScoped<ILocalTimeService, LocalTimeService>();
35-
services.TryAddSingleton<TimeProvider>(timeProvider);
55+
services.AddSingleton<BlazorLocalTimeConfiguration>(_ =>
56+
{
57+
var config = new BlazorLocalTimeConfiguration();
58+
configuration(config);
59+
return config;
60+
});
3661
return services;
3762
}
3863
}

src/BlazorLocalTime/Components/BlazorLocalTimeProvider.razor.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Microsoft.AspNetCore.Components;
1+
using System.Globalization;
2+
using Microsoft.AspNetCore.Components;
23
using Microsoft.Extensions.Logging;
34
using Microsoft.JSInterop;
45

@@ -21,6 +22,9 @@ public sealed partial class BlazorLocalTimeProvider : ComponentBase
2122
[Inject]
2223
private ILogger<BlazorLocalTimeProvider> Logger { get; set; } = null!;
2324

25+
[Inject]
26+
private BlazorLocalTimeConfiguration Configuration { get; set; } = null!;
27+
2428
/// <inheritdoc />
2529
protected override async Task OnAfterRenderAsync(bool firstRender)
2630
{
@@ -34,6 +38,20 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
3438
JsPath
3539
);
3640
var timeZoneString = await module.InvokeAsync<string>("getBrowserTimeZone");
41+
if (!IsIcuEnabled())
42+
{
43+
// On Windows with NLS mode, IANA time zone are must be converted to Windows time zone.
44+
var converter = Configuration.IanaToWindows;
45+
if (converter == null)
46+
{
47+
var message = """
48+
In older Windows environments, IANA time zones (such as “Asia/Tokyo”) cannot be used directly.
49+
For details, see https://github.com/arika0093/BlazorLocalTime/issues/19.
50+
""";
51+
throw new TimeZoneNotFoundException(message);
52+
}
53+
timeZoneString = converter(timeZoneString);
54+
}
3755
timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneString);
3856
}
3957
catch (JSDisconnectedException ex)
@@ -59,4 +77,13 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
5977
}
6078
}
6179
}
80+
81+
// https://learn.microsoft.com/en-us/dotnet/core/extensions/globalization-icu#determine-if-your-app-is-using-icu
82+
private static bool IsIcuEnabled()
83+
{
84+
SortVersion sortVersion = CultureInfo.InvariantCulture.CompareInfo.Version;
85+
byte[] bytes = sortVersion.SortId.ToByteArray();
86+
int version = bytes[3] << 24 | bytes[2] << 16 | bytes[1] << 8 | bytes[0];
87+
return version != 0 && version == sortVersion.FullVersion;
88+
}
6289
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
3+
namespace BlazorLocalTime;
4+
5+
/// <summary>
6+
/// Provides an interface for a local time service.
7+
/// </summary>
8+
public interface ILocalTimeService
9+
{
10+
/// <summary>
11+
/// Browser's time zone information. if you want to override it, set <see cref="OverrideTimeZoneInfo"/>.
12+
/// </summary>
13+
TimeZoneInfo? TimeZoneInfo { get; }
14+
15+
/// <summary>
16+
/// Browser's original time zone information (read-only).
17+
/// </summary>
18+
TimeZoneInfo? BrowserTimeZoneInfo { get; }
19+
20+
/// <summary>
21+
/// User-specified override time zone information. If you want to reset it, set this property to null.
22+
/// </summary>
23+
TimeZoneInfo? OverrideTimeZoneInfo { get; set; }
24+
25+
/// <summary>
26+
/// Gets the current browser's local time as a <see cref="DateTimeOffset"/>.
27+
/// </summary>
28+
DateTimeOffset Now { get; }
29+
30+
/// <summary>
31+
/// On local time zone changed event with detailed timezone information.
32+
/// </summary>
33+
event EventHandler<TimeZoneChangedEventArgs> LocalTimeZoneChanged;
34+
35+
/// <summary>
36+
/// Is the local time zone set?
37+
/// </summary>
38+
[MemberNotNullWhen(true, nameof(TimeZoneInfo))]
39+
public bool IsTimeZoneInfoAvailable => TimeZoneInfo != null;
40+
41+
/// <summary>
42+
/// Is the browser's time zone information successfully loaded?
43+
/// This property is set to null until the browser's time zone information is loaded.
44+
/// If the browser's time zone information is successfully loaded, it will be set to true.
45+
/// </summary>
46+
internal bool? IsSuccessLoadBrowserTimeZone { get; set; }
47+
48+
/// <summary>
49+
/// Sets the browser's time zone information.
50+
/// this method is only for internal use.
51+
/// </summary>
52+
internal void SetBrowserTimeZoneInfo(TimeZoneInfo? timeZoneInfo);
53+
54+
/// <summary>
55+
/// Converts the specified UTC <see cref="DateTime"/> to local time.
56+
/// </summary>
57+
/// <param name="utcDateTime">The UTC date and time to convert.</param>
58+
/// <returns>The local <see cref="DateTime"/>.</returns>
59+
public DateTime ToLocalTime(DateTime utcDateTime)
60+
{
61+
return TimeZoneInfo.ConvertTimeFromUtc(utcDateTime, GetBrowserTimeZone());
62+
}
63+
64+
/// <summary>
65+
/// Converts the <see cref="DateTimeOffset"/> to local time as a <see cref="DateTime"/>.
66+
/// </summary>
67+
/// <param name="dateTimeOffset"> The UTC date and time offset to convert.</param>
68+
/// <returns>The local <see cref="DateTime"/>.</returns>
69+
public DateTime ToLocalTime(DateTimeOffset dateTimeOffset)
70+
{
71+
return ToLocalTime(dateTimeOffset.UtcDateTime);
72+
}
73+
74+
/// <summary>
75+
/// Converts the specified UTC <see cref="DateTime"/> to a local <see cref="DateTimeOffset"/>.
76+
/// </summary>
77+
/// <param name="utcDateTime">The UTC date and time to convert.</param>
78+
/// <returns>The local <see cref="DateTimeOffset"/>.</returns>
79+
public DateTimeOffset ToLocalTimeOffset(DateTime utcDateTime)
80+
{
81+
return new(ToLocalTime(utcDateTime), GetBrowserTimeZone().GetUtcOffset(utcDateTime));
82+
}
83+
84+
/// <summary>
85+
/// Converts the <see cref="DateTimeOffset"/> to a local <see cref="DateTimeOffset"/>.
86+
/// </summary>
87+
/// <param name="dateTimeOffset">The UTC date and time to convert.</param>
88+
/// <returns>The local <see cref="DateTimeOffset"/>.</returns>
89+
public DateTimeOffset ToLocalTimeOffset(DateTimeOffset dateTimeOffset)
90+
{
91+
return new(ToLocalTime(dateTimeOffset), GetBrowserTimeZone().GetUtcOffset(dateTimeOffset));
92+
}
93+
94+
/// <summary>
95+
/// Gets the browser's time zone information.
96+
/// </summary>
97+
/// <returns>The <see cref="TimeZoneInfo"/> representing the browser's time zone.</returns>
98+
public TimeZoneInfo GetBrowserTimeZone()
99+
{
100+
if (!IsTimeZoneInfoAvailable)
101+
{
102+
throw new InvalidOperationException(
103+
"""
104+
Failed to obtain the browser's time zone information.
105+
Possible causes:
106+
1) The `<BlazorLocalTimeProvider />` component has not been added.
107+
In this case, please add `<BlazorLocalTimeProvider />` to a root component such as `Routes.razor`.
108+
2) You are trying to use `ILocalTimeService` in `OnInitialized(Async)`.
109+
In this case, you need to subscribe to the `ILocalTimeService.OnLocalTimeZoneChanged` event
110+
and perform processing after the time zone information has been set.
111+
"""
112+
);
113+
}
114+
return TimeZoneInfo;
115+
}
116+
}

0 commit comments

Comments
 (0)